Updated on 2026-08-14
This commit is contained in:
commit
02e65a84ef
72 changed files with 2922 additions and 267 deletions
|
|
@ -74,6 +74,10 @@ When the user asks to **port** an iOS test to Android:
|
|||
strings inside `step(...)`.
|
||||
- **Each click is its own** `step("Click on '$x' button")`. Combining clicks into one step hides which
|
||||
click failed in the Allure report.
|
||||
- **No reusable step-helpers as private functions in the test class.** A sequence reused across tests
|
||||
(e.g. `enterAmount`, `assertReady`) goes in a `scenarios/` file as a `BaseTestCase` extension, not as a
|
||||
private method on the test class — reviewers reject the latter. The test body then calls it wrapped in a
|
||||
`step(...)` like any scenario.
|
||||
- **Every scenario call in the test body is wrapped in its own `step("…")`**, even though the scenario
|
||||
itself contains inner `step(...)`s — the outer step names the flow in the Allure tree, the inner ones
|
||||
detail it (nested steps are expected). `step(...)` (Allure) is callable anywhere, including inside
|
||||
|
|
@ -121,13 +125,20 @@ Scenario files orchestrate flows; they must not define page objects or duplicate
|
|||
|
||||
### Waits and synchronization
|
||||
|
||||
- **Manual polls are banned** (`onAllNodes(matcher).fetchSemanticsNodes().isNotEmpty()` in a loop). Use:
|
||||
- `composeTestRule.waitUntilAtLeastOneExists(matcher, timeoutMillis)` — wait for one thing to appear.
|
||||
- `composeTestRule.waitUntil(timeout) { runCatching { someAssertion() }.isSuccess }` — wait until an
|
||||
action no longer throws.
|
||||
- `composeTestRule.waitUntil(timeout) { matcherA exists || matcherB exists }` — the either/or case.
|
||||
- **`flakySafely(timeout)`** (Kaspresso) is reachable only from `TestCase` subclasses, NOT from
|
||||
extension functions on `BaseTestCase`. In extension code use the `waitUntil` variants above.
|
||||
- **Manual polls are banned** (`onAllNodes(matcher).fetchSemanticsNodes().isNotEmpty()` in a loop) — even
|
||||
if a bot reviewer suggests one.
|
||||
- **Default in the test body: `flakySafely(TIMEOUT) { assertion }`** — the codebase idiom (hundreds of
|
||||
uses); reviewers prefer it over `composeTestRule.waitUntil { runCatching { … }.isSuccess }`.
|
||||
- **`ComposeNotIdleException` / `AppNotIdleException` ("busy for ~60s") is usually a sick emulator, not
|
||||
your test.** After many back-to-back local runs the emulator degrades (you may even see a "System UI
|
||||
isn't responding" ANR), and idle-synced ops (`flakySafely`, `waitForIdle()`, Kakao actions) start
|
||||
timing out *anywhere* data is loading — different test each run. Before concluding a test is flaky or
|
||||
that a screen "never idles", **cold-boot a fresh emulator** (`emulator -avd … -no-snapshot -wipe-data
|
||||
-memory 4096 -cores 2`) and re-run. A suite that flaked across runs on a tired emulator can be a clean
|
||||
10/10 on a fresh one (verified on this exact suite). Don't rewrite waits to work around emulator rot.
|
||||
- **In scenario / `BaseTestCase`-extension code, `flakySafely` is NOT available** regardless — use the
|
||||
same `composeTestRule.waitUntil` fallback (or `waitUntilAtLeastOneExists(matcher, timeout)` to wait for
|
||||
appearance, `{ a exists || b exists }` for either/or).
|
||||
|
||||
### Comment hygiene
|
||||
|
||||
|
|
@ -145,4 +156,5 @@ Delete anything explaining WHAT a step does.
|
|||
look like passing tests.
|
||||
- **`reference/running-and-debugging.md`** — read when building, installing, running tests (orchestrator
|
||||
vs. raw `am instrument`), running against a local WireMock, interpreting CLI/Allure output, using
|
||||
`@Ignore`, or driving WireMock scenarios.
|
||||
`@Ignore`, or driving WireMock scenarios. Includes how to find app-side root causes when the UI fails
|
||||
silently (the app log in `files/log.txt`, and the WireMock journal).
|
||||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -100,6 +100,29 @@ curl -s http://localhost:8081/__admin/requests/unmatched | jq '.requests[] | "\(
|
|||
(harness/emulator) for the hang. A non-empty list names exactly which mapping (or scenario state) the
|
||||
local instance is missing.
|
||||
|
||||
## When the UI fails silently, the cause is usually app-side — two places to look
|
||||
|
||||
A screen failing silently with correct locators (fee shows "—", a banner never appears, a button stays
|
||||
disabled) is usually missing mock *data* or an app-side gate, not a test bug. Two diagnostics find it:
|
||||
|
||||
- **The app's own log is in `files/log.txt`, not logcat** — the mocked build routes `TangemLogger` to a
|
||||
file, so `adb logcat` shows nothing. Fastest path to a root cause (e.g. it surfaced
|
||||
`IllegalStateException: No native currency found` → a native coin missing from the mock):
|
||||
```bash
|
||||
adb exec-out run-as <pkg> cat files/log.txt | grep -iE "Error|Exception|<feature>"
|
||||
```
|
||||
- **The WireMock journal separates "mock missing" from "app never asked"** —
|
||||
`/__admin/requests/unmatched` finds missing mappings, but if `unmatched=0` *and* the expected request
|
||||
is also absent from the full log (`/__admin/requests`), the app never issued it (a data/state gate) →
|
||||
fix the mock data or the app, not the mappings.
|
||||
|
||||
## "UiAutomationService already registered" — retry, it's not a failure
|
||||
|
||||
Back-to-back `am instrument` runs sometimes fail instantly with `UiAutomationService … already
|
||||
registered!` — a teardown race between runs, not a test failure. Retry. (The orchestrator avoids it by
|
||||
spacing runs — another reason to confirm a flaky-looking suite via the orchestrator, not raw
|
||||
`am instrument`.)
|
||||
|
||||
## Classify the result — Allure noise vs. real failure
|
||||
|
||||
After `pm clear`, `/data/user/0/<pkg>/files/original_screenshots` doesn't exist →
|
||||
|
|
|
|||
|
|
@ -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) }
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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)))
|
||||
|
|
|
|||
|
|
@ -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) =
|
||||
|
|
|
|||
|
|
@ -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() }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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,
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
|
|
|
|||
|
|
@ -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),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -67,7 +67,10 @@ fun TangemTab(
|
|||
interactionSource = remember { MutableInteractionSource() },
|
||||
indication = ripple(),
|
||||
)
|
||||
.padding(all = TangemTheme.dimens2.x3),
|
||||
.padding(
|
||||
vertical = TangemTheme.dimens2.x2,
|
||||
horizontal = TangemTheme.dimens2.x3,
|
||||
),
|
||||
) {
|
||||
Text(
|
||||
text = text.resolveReference(),
|
||||
|
|
|
|||
Binary file not shown.
|
After Width: | Height: | Size: 2.4 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 3.3 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 5.4 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 8 KiB |
|
|
@ -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",
|
||||
),
|
||||
),
|
||||
),
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -19,8 +19,10 @@ import com.tangem.domain.utils.convertToSdkAmount
|
|||
import com.tangem.domain.walletmanager.WalletManagersFacade
|
||||
import com.tangem.domain.yield.supply.YieldSupplyTransactionRepository
|
||||
import com.tangem.utils.coroutines.CoroutineDispatcherProvider
|
||||
import com.tangem.utils.coroutines.runSuspendCatching
|
||||
import com.tangem.utils.extensions.orZero
|
||||
import com.tangem.utils.logging.TangemLogger
|
||||
import kotlinx.coroutines.CancellationException
|
||||
import kotlinx.coroutines.withContext
|
||||
import java.math.BigDecimal
|
||||
|
||||
|
|
@ -98,7 +100,7 @@ internal class DefaultYieldSupplyTransactionRepository(
|
|||
cryptoCurrency: CryptoCurrency,
|
||||
): BigDecimal? = withContext(dispatchers.io) {
|
||||
require(cryptoCurrency is CryptoCurrency.Token)
|
||||
runCatching {
|
||||
runSuspendCatching {
|
||||
val walletManager = walletManagersFacade.getOrCreateWalletManager(
|
||||
userWalletId = userWalletId,
|
||||
blockchain = cryptoCurrency.network.toBlockchain(),
|
||||
|
|
@ -111,7 +113,7 @@ internal class DefaultYieldSupplyTransactionRepository(
|
|||
decimals = cryptoCurrency.decimals,
|
||||
),
|
||||
)
|
||||
}.onFailure { TangemLogger.e("Error", it) }.getOrThrow()
|
||||
}.logErrorUnlessCancellation().getOrThrow()
|
||||
}
|
||||
|
||||
@Suppress("LongParameterList")
|
||||
|
|
@ -202,27 +204,27 @@ internal class DefaultYieldSupplyTransactionRepository(
|
|||
cryptoCurrency: CryptoCurrency,
|
||||
): String? = withContext(dispatchers.io) {
|
||||
require(cryptoCurrency is CryptoCurrency.Token)
|
||||
runCatching {
|
||||
runSuspendCatching {
|
||||
val walletManager = walletManagersFacade.getOrCreateWalletManager(
|
||||
userWalletId = userWalletId,
|
||||
blockchain = cryptoCurrency.network.toBlockchain(),
|
||||
derivationPath = cryptoCurrency.network.derivationPath.value,
|
||||
) ?: error("Wallet manager not found")
|
||||
walletManager.calculateYieldModuleAddress()
|
||||
}.onFailure { TangemLogger.e("Error", it) }.getOrThrow()
|
||||
}.logErrorUnlessCancellation().getOrThrow()
|
||||
}
|
||||
|
||||
override suspend fun getYieldContractAddress(userWalletId: UserWalletId, cryptoCurrency: CryptoCurrency): String? =
|
||||
withContext(dispatchers.io) {
|
||||
require(cryptoCurrency is CryptoCurrency.Token)
|
||||
runCatching {
|
||||
runSuspendCatching {
|
||||
val walletManager = walletManagersFacade.getOrCreateWalletManager(
|
||||
userWalletId = userWalletId,
|
||||
blockchain = cryptoCurrency.network.toBlockchain(),
|
||||
derivationPath = cryptoCurrency.network.derivationPath.value,
|
||||
) ?: error("Wallet manager not found")
|
||||
walletManager.getYieldModuleAddress()
|
||||
}.onFailure { TangemLogger.e("Error", it) }.getOrThrow()
|
||||
}.logErrorUnlessCancellation().getOrThrow()
|
||||
}
|
||||
|
||||
override suspend fun wrapYieldSwapCallDataWithUpgradeIfNeeded(
|
||||
|
|
@ -274,7 +276,7 @@ internal class DefaultYieldSupplyTransactionRepository(
|
|||
walletManager: WalletManager,
|
||||
cryptoCurrency: CryptoCurrency.Token,
|
||||
): YieldSupplyStatus? = withContext(dispatchers.io) {
|
||||
runCatching {
|
||||
runSuspendCatching {
|
||||
val sdkSupplyStatus = walletManager.getYieldSupplyStatus(cryptoCurrency.contractAddress)
|
||||
val protocolBalance = if (sdkSupplyStatus?.isActive == true) {
|
||||
walletManager.getEffectiveProtocolBalance(
|
||||
|
|
@ -301,7 +303,20 @@ internal class DefaultYieldSupplyTransactionRepository(
|
|||
isAllowedToSpend = isAllowedToSpend,
|
||||
effectiveProtocolBalance = protocolBalance,
|
||||
)
|
||||
}.onFailure { TangemLogger.e("Error", it) }.getOrNull()
|
||||
}.logErrorUnlessCancellation().getOrThrow()
|
||||
}
|
||||
|
||||
/**
|
||||
* Logs failures via [TangemLogger] but rethrows coroutine cancellation so a cancelled scope
|
||||
* (e.g. leaving the yield-supply screen) cancels cleanly instead of being reported as a real
|
||||
* error and swallowed to `null`. Covers both a raw [CancellationException] and one wrapped by
|
||||
* the blockchain SDK as [BlockchainSdkError.WrappedThrowable] (carried in [Throwable.cause]),
|
||||
* which is what `EthereumLikeJsonRpcProvider.post` throws when an in-flight RPC is cancelled.
|
||||
*/
|
||||
private fun <T> Result<T>.logErrorUnlessCancellation(): Result<T> = onFailure { error ->
|
||||
val cancellation = error as? CancellationException ?: error.cause as? CancellationException
|
||||
if (cancellation != null) throw cancellation
|
||||
TangemLogger.e("Error", error)
|
||||
}
|
||||
|
||||
private fun createDeployTransaction(
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
|
||||
|
|
|
|||
|
|
@ -40,7 +40,7 @@ internal class IssueAdditionalCardUseCaseTest {
|
|||
private val offer = Offer(
|
||||
type = Offer.Type.CARD_ISSUE_VIRTUAL_RAIN,
|
||||
fee = Offer.Fee(amount = BigDecimal("1.00"), currency = Currency.getInstance("USD")),
|
||||
data = Offer.Data(specificationName = spec, orderType = OrderType.CARD_ISSUE_ADDITIONAL),
|
||||
data = Offer.Data(specificationName = spec, orderType = OrderType.CARD_ISSUE_VIRTUAL_RAIN_KYC),
|
||||
)
|
||||
|
||||
@Test
|
||||
|
|
@ -58,7 +58,7 @@ internal class IssueAdditionalCardUseCaseTest {
|
|||
fun `WHEN active issue order exists THEN reuses it without calling createOrder`() = runTest {
|
||||
val existing = order(
|
||||
id = "existing",
|
||||
type = OrderType.CARD_ISSUE_ADDITIONAL,
|
||||
type = OrderType.CARD_ISSUE_VIRTUAL_RAIN_KYC,
|
||||
status = OrderStatus.PROCESSING,
|
||||
)
|
||||
coEvery { offersRepository.getOffers(userWalletId) } returns listOf(offer).right()
|
||||
|
|
@ -66,7 +66,6 @@ internal class IssueAdditionalCardUseCaseTest {
|
|||
orderRepository.findOrders(
|
||||
userWalletId,
|
||||
types = setOf(
|
||||
OrderType.CARD_ISSUE_ADDITIONAL,
|
||||
OrderType.CARD_ISSUE_VIRTUAL_RAIN,
|
||||
OrderType.CARD_ISSUE_VIRTUAL_RAIN_KYC,
|
||||
OrderType.CARD_ISSUE_VIRTUAL_RAIN_KYC_V2,
|
||||
|
|
@ -89,7 +88,6 @@ internal class IssueAdditionalCardUseCaseTest {
|
|||
orderRepository.findOrders(
|
||||
userWalletId,
|
||||
types = setOf(
|
||||
OrderType.CARD_ISSUE_ADDITIONAL,
|
||||
OrderType.CARD_ISSUE_VIRTUAL_RAIN,
|
||||
OrderType.CARD_ISSUE_VIRTUAL_RAIN_KYC,
|
||||
OrderType.CARD_ISSUE_VIRTUAL_RAIN_KYC_V2,
|
||||
|
|
@ -100,7 +98,7 @@ internal class IssueAdditionalCardUseCaseTest {
|
|||
coEvery {
|
||||
orderRepository.createOrder(
|
||||
userWalletId = userWalletId,
|
||||
type = OrderType.CARD_ISSUE_ADDITIONAL,
|
||||
type = OrderType.CARD_ISSUE_VIRTUAL_RAIN_KYC,
|
||||
specificationName = spec,
|
||||
idempotencyKey = any(),
|
||||
)
|
||||
|
|
@ -118,7 +116,6 @@ internal class IssueAdditionalCardUseCaseTest {
|
|||
orderRepository.findOrders(
|
||||
userWalletId,
|
||||
types = setOf(
|
||||
OrderType.CARD_ISSUE_ADDITIONAL,
|
||||
OrderType.CARD_ISSUE_VIRTUAL_RAIN,
|
||||
OrderType.CARD_ISSUE_VIRTUAL_RAIN_KYC,
|
||||
OrderType.CARD_ISSUE_VIRTUAL_RAIN_KYC_V2,
|
||||
|
|
@ -128,13 +125,13 @@ internal class IssueAdditionalCardUseCaseTest {
|
|||
} returns emptyList<Order>().right()
|
||||
val newOrder = order(
|
||||
id = "new",
|
||||
type = OrderType.CARD_ISSUE_ADDITIONAL,
|
||||
type = OrderType.CARD_ISSUE_VIRTUAL_RAIN_KYC,
|
||||
status = OrderStatus.NEW,
|
||||
)
|
||||
coEvery {
|
||||
orderRepository.createOrder(
|
||||
userWalletId = userWalletId,
|
||||
type = OrderType.CARD_ISSUE_ADDITIONAL,
|
||||
type = OrderType.CARD_ISSUE_VIRTUAL_RAIN_KYC,
|
||||
specificationName = spec,
|
||||
idempotencyKey = any(),
|
||||
)
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
|
|
|
|||
|
|
@ -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].
|
||||
|
|
|
|||
|
|
@ -28,7 +28,7 @@ internal class MarketsListBatchFlowManager @AssistedInject constructor(
|
|||
getSelectedAppCurrencyUseCase: GetSelectedAppCurrencyUseCase,
|
||||
private val dispatchers: CoroutineDispatcherProvider,
|
||||
@Assisted private val batchFlowType: GetMarketsTokenListFlowUseCase.BatchFlowType,
|
||||
@Assisted private val order: TokenMarketListConfig.Order,
|
||||
@Assisted private val currentOrder: Provider<TokenMarketListConfig.Order>,
|
||||
@Assisted private val currentSearchText: Provider<String?>,
|
||||
@Assisted private val modelScope: CoroutineScope,
|
||||
) {
|
||||
|
|
@ -192,7 +192,7 @@ internal class MarketsListBatchFlowManager @AssistedInject constructor(
|
|||
searchText ?: currentSearchText()
|
||||
},
|
||||
priceChangeInterval = TokenMarketListConfig.Interval.H24,
|
||||
order = order,
|
||||
order = currentOrder(),
|
||||
shouldNetworks = true,
|
||||
),
|
||||
),
|
||||
|
|
@ -262,7 +262,7 @@ internal class MarketsListBatchFlowManager @AssistedInject constructor(
|
|||
interface Factory {
|
||||
fun create(
|
||||
batchFlowType: GetMarketsTokenListFlowUseCase.BatchFlowType,
|
||||
order: TokenMarketListConfig.Order,
|
||||
currentOrder: Provider<TokenMarketListConfig.Order>,
|
||||
currentSearchText: Provider<String?>,
|
||||
modelScope: CoroutineScope,
|
||||
): MarketsListBatchFlowManager
|
||||
|
|
|
|||
|
|
@ -0,0 +1,49 @@
|
|||
package com.tangem.features.commonfeatures.impl.choosetoken.market.state
|
||||
|
||||
import androidx.compose.runtime.Immutable
|
||||
import com.tangem.core.ui.R
|
||||
import com.tangem.core.ui.extensions.TextReference
|
||||
import com.tangem.core.ui.extensions.resourceReference
|
||||
import com.tangem.domain.markets.TokenMarketListConfig
|
||||
import kotlinx.collections.immutable.ImmutableList
|
||||
|
||||
/**
|
||||
* Category of the "Market Pulse" block on the Choose asset screen.
|
||||
*
|
||||
* Each category maps to a [TokenMarketListConfig.Order] used to fetch the markets list.
|
||||
*/
|
||||
internal enum class SwapMarketCategory(
|
||||
val title: TextReference,
|
||||
val order: TokenMarketListConfig.Order,
|
||||
) {
|
||||
Trending(
|
||||
title = resourceReference(R.string.markets_sort_by_trending_title),
|
||||
order = TokenMarketListConfig.Order.Trending,
|
||||
),
|
||||
ExperiencedBuyers(
|
||||
title = resourceReference(R.string.markets_sort_by_experienced_buyers_title),
|
||||
order = TokenMarketListConfig.Order.Buyers,
|
||||
),
|
||||
TopGainers(
|
||||
title = resourceReference(R.string.markets_sort_by_top_gainers_title),
|
||||
order = TokenMarketListConfig.Order.TopGainers,
|
||||
),
|
||||
TopLosers(
|
||||
title = resourceReference(R.string.markets_sort_by_top_losers_title),
|
||||
order = TokenMarketListConfig.Order.TopLosers,
|
||||
),
|
||||
}
|
||||
|
||||
/**
|
||||
* UI model for the selectable category tabs of the "Market Pulse" block.
|
||||
*
|
||||
* @property items all available categories in display order.
|
||||
* @property selected currently selected category.
|
||||
* @property onCategoryClick invoked when a category tab is tapped.
|
||||
*/
|
||||
@Immutable
|
||||
internal data class SwapMarketCategoriesUM(
|
||||
val items: ImmutableList<SwapMarketCategory>,
|
||||
val selected: SwapMarketCategory,
|
||||
val onCategoryClick: (SwapMarketCategory) -> Unit,
|
||||
)
|
||||
|
|
@ -13,6 +13,8 @@ internal sealed class SwapMarketState {
|
|||
abstract val marketsTitle: TextReference
|
||||
abstract val shouldAssetsCount: Boolean
|
||||
|
||||
open val categories: SwapMarketCategoriesUM? = null
|
||||
|
||||
data class Content(
|
||||
val items: ImmutableList<MarketsListItemUM>,
|
||||
val total: Int,
|
||||
|
|
@ -21,17 +23,20 @@ internal sealed class SwapMarketState {
|
|||
val visibleIdsChanged: (List<CryptoCurrency.RawID>) -> Unit,
|
||||
override val marketsTitle: TextReference,
|
||||
override val shouldAssetsCount: Boolean,
|
||||
override val categories: SwapMarketCategoriesUM? = null,
|
||||
) : SwapMarketState()
|
||||
|
||||
data class Loading(
|
||||
override val marketsTitle: TextReference,
|
||||
override val shouldAssetsCount: Boolean,
|
||||
override val categories: SwapMarketCategoriesUM? = null,
|
||||
) : SwapMarketState()
|
||||
|
||||
data class LoadingError(
|
||||
val onRetryClicked: () -> Unit,
|
||||
override val marketsTitle: TextReference,
|
||||
override val shouldAssetsCount: Boolean,
|
||||
override val categories: SwapMarketCategoriesUM? = null,
|
||||
) : SwapMarketState()
|
||||
|
||||
data object SearchNothingFound : SwapMarketState() {
|
||||
|
|
@ -40,11 +45,6 @@ internal sealed class SwapMarketState {
|
|||
}
|
||||
|
||||
companion object {
|
||||
val DefaultLoading
|
||||
get() = Loading(
|
||||
marketsTitle = TextReference.Res(R.string.feed_trending_now),
|
||||
shouldAssetsCount = false,
|
||||
)
|
||||
val SearchLoading
|
||||
get() = Loading(
|
||||
marketsTitle = TextReference.Res(R.string.markets_common_title),
|
||||
|
|
|
|||
|
|
@ -22,6 +22,8 @@ import com.tangem.features.commonfeatures.api.choosetoken.ChooseTokenBridgeInter
|
|||
import com.tangem.features.commonfeatures.api.choosetoken.ChooseTokenBridgeInternal.SearchQuery.Companion.isSearchingState
|
||||
import com.tangem.features.commonfeatures.impl.choosetoken.AddToPortfolioRoute
|
||||
import com.tangem.features.commonfeatures.impl.choosetoken.market.MarketsListBatchFlowManager
|
||||
import com.tangem.features.commonfeatures.impl.choosetoken.market.state.SwapMarketCategoriesUM
|
||||
import com.tangem.features.commonfeatures.impl.choosetoken.market.state.SwapMarketCategory
|
||||
import com.tangem.features.commonfeatures.impl.choosetoken.market.state.SwapMarketState
|
||||
import com.tangem.lib.crypto.BlockchainUtils
|
||||
import com.tangem.utils.Provider
|
||||
|
|
@ -49,6 +51,8 @@ internal class MarketBlockDelegate @AssistedInject constructor(
|
|||
private val visibleMarketItemIds = MutableStateFlow<List<CryptoCurrency.RawID>>(emptyList())
|
||||
private val visibleDefaultMarketItemIds = MutableStateFlow<List<CryptoCurrency.RawID>>(emptyList())
|
||||
|
||||
private val selectedCategoryFlow = MutableStateFlow(SwapMarketCategory.Trending)
|
||||
|
||||
val addToPortfolioSlot: SlotNavigation<AddToPortfolioRoute> = SlotNavigation()
|
||||
val addToPortfolioManager: AddToPortfolioManager = addToPortfolioManagerFactory.create(
|
||||
scope = modelScope,
|
||||
|
|
@ -91,7 +95,7 @@ internal class MarketBlockDelegate @AssistedInject constructor(
|
|||
private val defaultMarketsListManager by lazy {
|
||||
marketsListBatchFlowManagerFactory.create(
|
||||
batchFlowType = GetMarketsTokenListFlowUseCase.BatchFlowType.Main,
|
||||
order = TokenMarketListConfig.Order.Trending,
|
||||
currentOrder = Provider { selectedCategoryFlow.value.order },
|
||||
currentSearchText = Provider { null },
|
||||
modelScope = modelScope,
|
||||
)
|
||||
|
|
@ -100,7 +104,7 @@ internal class MarketBlockDelegate @AssistedInject constructor(
|
|||
private val searchMarketsListManager by lazy {
|
||||
marketsListBatchFlowManagerFactory.create(
|
||||
batchFlowType = GetMarketsTokenListFlowUseCase.BatchFlowType.Search,
|
||||
order = TokenMarketListConfig.Order.ByRating,
|
||||
currentOrder = Provider { TokenMarketListConfig.Order.ByRating },
|
||||
currentSearchText = Provider { searchQueryState.value.value },
|
||||
modelScope = modelScope,
|
||||
)
|
||||
|
|
@ -148,19 +152,26 @@ internal class MarketBlockDelegate @AssistedInject constructor(
|
|||
}
|
||||
|
||||
private fun createDefaultMarketsFlow(): Flow<SwapMarketState> {
|
||||
val marketsTitle = TextReference.Res(R.string.feed_trending_now)
|
||||
val marketsTitle = TextReference.Res(R.string.markets_pulse_common_title)
|
||||
return combine(
|
||||
defaultMarketsListManager.uiItems,
|
||||
defaultMarketsListManager.isInInitialLoadingErrorState,
|
||||
defaultMarketsListManager.totalCount,
|
||||
) { uiItems, isError, total ->
|
||||
flow = defaultMarketsListManager.uiItems,
|
||||
flow2 = defaultMarketsListManager.isInInitialLoadingErrorState,
|
||||
flow3 = defaultMarketsListManager.totalCount,
|
||||
flow4 = selectedCategoryFlow,
|
||||
) { uiItems, isError, total, selectedCategory ->
|
||||
val categories = buildCategoriesUM(selectedCategory)
|
||||
when {
|
||||
isError -> SwapMarketState.LoadingError(
|
||||
onRetryClicked = { defaultMarketsListManager.reload() },
|
||||
marketsTitle = marketsTitle,
|
||||
shouldAssetsCount = false,
|
||||
categories = categories,
|
||||
)
|
||||
uiItems.isEmpty() -> SwapMarketState.Loading(
|
||||
marketsTitle = marketsTitle,
|
||||
shouldAssetsCount = false,
|
||||
categories = categories,
|
||||
)
|
||||
uiItems.isEmpty() -> SwapMarketState.DefaultLoading
|
||||
else -> SwapMarketState.Content(
|
||||
items = uiItems,
|
||||
loadMore = { defaultMarketsListManager.loadMore() },
|
||||
|
|
@ -169,11 +180,24 @@ internal class MarketBlockDelegate @AssistedInject constructor(
|
|||
total = total ?: uiItems.size,
|
||||
marketsTitle = marketsTitle,
|
||||
shouldAssetsCount = false,
|
||||
categories = categories,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun buildCategoriesUM(selected: SwapMarketCategory): SwapMarketCategoriesUM = SwapMarketCategoriesUM(
|
||||
items = SwapMarketCategory.entries.toImmutableList(),
|
||||
selected = selected,
|
||||
onCategoryClick = ::onCategorySelected,
|
||||
)
|
||||
|
||||
private fun onCategorySelected(category: SwapMarketCategory) {
|
||||
if (selectedCategoryFlow.value == category) return
|
||||
selectedCategoryFlow.value = category
|
||||
defaultMarketsListManager.reload()
|
||||
}
|
||||
|
||||
private fun createSearchMarketsFlow(): Flow<SwapMarketState> {
|
||||
val marketsTitle = TextReference.Res(R.string.markets_common_title)
|
||||
return combine(
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
)
|
||||
},
|
||||
)
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -7,6 +7,7 @@ import com.tangem.domain.express.models.ExpressOperationType
|
|||
import com.tangem.domain.models.currency.CryptoCurrencyStatus
|
||||
import com.tangem.domain.swap.models.SwapCurrencyStatus
|
||||
import com.tangem.domain.transaction.error.GetFeeError
|
||||
import com.tangem.domain.transaction.models.AssetRequirementsCondition
|
||||
import com.tangem.feature.swap.domain.models.SwapAmount
|
||||
import com.tangem.feature.swap.domain.models.domain.*
|
||||
import com.tangem.feature.swap.domain.models.ui.IntegratedApprovalData
|
||||
|
|
@ -29,6 +30,8 @@ interface SwapInteractor {
|
|||
pairs: List<SwapPairLeast>,
|
||||
): List<SwapProvider>
|
||||
|
||||
suspend fun getUnfulfilledReceiveRequirement(toSwapCurrencyStatus: SwapCurrencyStatus): AssetRequirementsCondition?
|
||||
|
||||
fun findProvidersForPair(
|
||||
fromSwapCurrencyStatus: SwapCurrencyStatus,
|
||||
toSwapCurrencyStatus: SwapCurrencyStatus,
|
||||
|
|
|
|||
|
|
@ -46,6 +46,7 @@ import com.tangem.domain.tokens.repository.CurrenciesRepository
|
|||
import com.tangem.domain.tokens.repository.CurrencyChecksRepository
|
||||
import com.tangem.domain.transaction.error.GetFeeError
|
||||
import com.tangem.domain.transaction.models.AllowanceInfo
|
||||
import com.tangem.domain.transaction.models.AssetRequirementsCondition
|
||||
import com.tangem.domain.transaction.usecase.*
|
||||
import com.tangem.domain.transaction.usecase.gasless.CreateAndSendGaslessTransactionUseCase
|
||||
import com.tangem.domain.utils.convertToSdkAmount
|
||||
|
|
@ -62,6 +63,7 @@ import com.tangem.feature.swap.domain.models.domain.*
|
|||
import com.tangem.feature.swap.domain.models.toStringWithRightOffset
|
||||
import com.tangem.feature.swap.domain.models.ui.*
|
||||
import com.tangem.features.swap.SwapFeatureToggles
|
||||
import com.tangem.lib.crypto.BlockchainFeeUtils.patchIntegratedApprovalPriorityFee
|
||||
import com.tangem.lib.crypto.BlockchainUtils.isBitcoin
|
||||
import com.tangem.lib.crypto.BlockchainUtils.isSolana
|
||||
import com.tangem.utils.coroutines.runSuspendCatching
|
||||
|
|
@ -129,11 +131,29 @@ internal class SwapInteractorImpl @Inject constructor(
|
|||
ConcurrentHashMap<IntegratedApprovalFallbackKey, Boolean>(),
|
||||
)
|
||||
|
||||
private val yieldSwapAllowedRouters = newSetFromMap(ConcurrentHashMap<String, Boolean>())
|
||||
|
||||
private fun hasIntegratedApprovalFallenBack(fromSwapCurrencyStatus: SwapCurrencyStatus, spenderAddress: String?) =
|
||||
integratedApprovalFallbackContexts.contains(
|
||||
IntegratedApprovalFallbackKey.of(fromSwapCurrencyStatus, spenderAddress),
|
||||
)
|
||||
|
||||
private suspend fun isYieldSwapRouterAllowed(
|
||||
fromSwapCurrencyStatus: SwapCurrencyStatus,
|
||||
routerAddress: String,
|
||||
): Boolean {
|
||||
val network = fromSwapCurrencyStatus.currency.network
|
||||
val key = "${network.rawId}:${routerAddress.lowercase()}"
|
||||
if (yieldSwapAllowedRouters.contains(key)) return true
|
||||
val isAllowed = walletManagersFacade.isSwapSpenderAllowed(
|
||||
userWalletId = fromSwapCurrencyStatus.userWalletId,
|
||||
network = network,
|
||||
spenderAddress = routerAddress,
|
||||
)
|
||||
if (isAllowed) yieldSwapAllowedRouters.add(key)
|
||||
return isAllowed
|
||||
}
|
||||
|
||||
override suspend fun getPair(
|
||||
fromSwapCurrencyStatus: SwapCurrencyStatus,
|
||||
toSwapCurrencyStatus: SwapCurrencyStatus,
|
||||
|
|
@ -207,12 +227,14 @@ internal class SwapInteractorImpl @Inject constructor(
|
|||
toSwapCurrencyStatus: SwapCurrencyStatus,
|
||||
pairs: List<SwapPairLeast>,
|
||||
): List<SwapProvider> {
|
||||
val requirements = getAssetRequirementsUseCase.invoke(
|
||||
val fromRequirements = getAssetRequirementsUseCase.invoke(
|
||||
fromSwapCurrencyStatus.userWalletId,
|
||||
fromSwapCurrencyStatus.currency,
|
||||
).getOrNull()
|
||||
|
||||
if (!rampStateManager.checkAssetRequirements(requirements)) {
|
||||
val isToFulfilled = getUnfulfilledReceiveRequirement(toSwapCurrencyStatus) == null
|
||||
|
||||
if (!rampStateManager.checkAssetRequirements(fromRequirements) || !isToFulfilled) {
|
||||
return emptyList()
|
||||
}
|
||||
|
||||
|
|
@ -223,6 +245,17 @@ internal class SwapInteractorImpl @Inject constructor(
|
|||
)
|
||||
}
|
||||
|
||||
override suspend fun getUnfulfilledReceiveRequirement(
|
||||
toSwapCurrencyStatus: SwapCurrencyStatus,
|
||||
): AssetRequirementsCondition? {
|
||||
val requirements = getAssetRequirementsUseCase.invoke(
|
||||
toSwapCurrencyStatus.userWalletId,
|
||||
toSwapCurrencyStatus.currency,
|
||||
).getOrNull()
|
||||
|
||||
return requirements?.takeUnless { rampStateManager.checkAssetRequirements(it) }
|
||||
}
|
||||
|
||||
override suspend fun findBestQuote(
|
||||
fromSwapCurrencyStatus: SwapCurrencyStatus,
|
||||
toSwapCurrencyStatus: SwapCurrencyStatus,
|
||||
|
|
@ -289,7 +322,7 @@ internal class SwapInteractorImpl @Inject constructor(
|
|||
}
|
||||
}
|
||||
}
|
||||
}.awaitAll().toMap()
|
||||
}.awaitAll().filterNotNull().toMap()
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -301,7 +334,7 @@ internal class SwapInteractorImpl @Inject constructor(
|
|||
amount: SwapAmount,
|
||||
reduceBalanceBy: BigDecimal,
|
||||
expressOperationType: ExpressOperationType,
|
||||
): Pair<SwapProvider, SwapState> {
|
||||
): Pair<SwapProvider, SwapState>? {
|
||||
if (fromSwapCurrencyStatus.status.value.yieldSupplyStatus?.isActive == true &&
|
||||
!swapFeatureToggles.isYieldSwapEnabled
|
||||
) {
|
||||
|
|
@ -351,6 +384,12 @@ internal class SwapInteractorImpl @Inject constructor(
|
|||
|
||||
val dexRouterSpenderAddress = maybeQuote.getOrNull()?.allowanceContract
|
||||
|
||||
if (isYieldSwap && dexRouterSpenderAddress != null &&
|
||||
!isYieldSwapRouterAllowed(fromSwapCurrencyStatus, dexRouterSpenderAddress)
|
||||
) {
|
||||
return null
|
||||
}
|
||||
|
||||
val allowanceInfo = spenderAddress?.let { allowanceContract ->
|
||||
getAllowanceInfoUseCase(
|
||||
userWalletId = fromSwapCurrencyStatus.userWalletId,
|
||||
|
|
@ -1446,11 +1485,13 @@ internal class SwapInteractorImpl @Inject constructor(
|
|||
raise(GetFeeError.DataError(error))
|
||||
}
|
||||
|
||||
val approvalFee = getFeeUseCase(
|
||||
transactionData = approvalTx,
|
||||
userWallet = fromStatus.userWallet,
|
||||
network = fromStatus.currency.network,
|
||||
).bind()
|
||||
val approvalFee = runSuspendCatching {
|
||||
getFeeUseCase(
|
||||
transactionData = approvalTx,
|
||||
userWallet = fromStatus.userWallet,
|
||||
network = fromStatus.currency.network,
|
||||
).bind().patchIntegratedApprovalPriorityFee(INCREASE_GAS_PRICE_FOR_INTEGRATED_APPROVAL)
|
||||
}.getOrElse { error -> raise(GetFeeError.DataError(error)) }
|
||||
|
||||
IntegratedApprovalData(
|
||||
approvalTransaction = approvalTx,
|
||||
|
|
@ -2431,6 +2472,8 @@ internal class SwapInteractorImpl @Inject constructor(
|
|||
}
|
||||
|
||||
companion object {
|
||||
private const val INCREASE_GAS_PRICE_FOR_INTEGRATED_APPROVAL = 115 // 15% increase
|
||||
|
||||
private val PRICE_IMPACT_AMOUNT_MIN_THRESHOLD = 25.toBigDecimal() // in USD
|
||||
private val PRICE_IMPACT_AMOUNT_MAX_THRESHOLD = 5000.toBigDecimal() // in USD
|
||||
private val PRICE_IMPACT_AMOUNT_LOW_THRESHOLD = 100_000.toBigDecimal() // in USD
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -53,4 +53,6 @@ interface SwapTransferInteractor {
|
|||
cryptoAmount: BigDecimal,
|
||||
toSwapCurrencyStatus: SwapCurrencyStatus,
|
||||
): Either<SendTransactionError, WithdrawalResult>
|
||||
|
||||
suspend fun incrementTronTokenFeeShowCount(cryptoCurrencyStatus: CryptoCurrencyStatus?)
|
||||
}
|
||||
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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`.
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -3,6 +3,7 @@ package com.tangem.feature.swap.domain
|
|||
import arrow.core.left
|
||||
import arrow.core.right
|
||||
import com.google.common.truth.Truth.assertThat
|
||||
import com.tangem.blockchain.common.Amount
|
||||
import com.tangem.blockchain.common.TransactionData
|
||||
import com.tangem.blockchain.common.transaction.Fee
|
||||
import com.tangem.blockchain.common.transaction.TransactionFee
|
||||
|
|
@ -17,6 +18,7 @@ import org.junit.jupiter.api.BeforeEach
|
|||
import org.junit.jupiter.api.Test
|
||||
import org.junit.jupiter.api.TestInstance
|
||||
import java.math.BigDecimal
|
||||
import java.math.BigInteger
|
||||
|
||||
/**
|
||||
* Tests for [SwapInteractorImpl.loadIntegratedApprovalData].
|
||||
|
|
@ -185,6 +187,211 @@ internal class SwapInteractorImplLoadIntegratedApprovalDataTest : SwapInteractor
|
|||
}
|
||||
}
|
||||
|
||||
// region patchIntegratedApprovalPriorityFee — INCREASE_GAS_PRICE_FOR_INTEGRATED_APPROVAL (115 = +15% gas-price)
|
||||
|
||||
/**
|
||||
* The loaded approval fee is patched via
|
||||
* [com.tangem.lib.crypto.BlockchainFeeUtils.patchIntegratedApprovalPriorityFee] before being
|
||||
* returned. Scales the **gas-price** fields (Legacy `gasPrice`; EIP1559
|
||||
* `maxFeePerGas` and `priorityFee`) and the derived `amount` for [Fee.Ethereum] legs by 15%;
|
||||
* The new `amount` is recomputed from `gasLimit * newGasPrice` shifted left by `decimals`,
|
||||
* independent of the input amount value.
|
||||
*/
|
||||
@Test
|
||||
fun `GIVEN Ethereum Legacy Single fee WHEN loaded THEN gasPrice and amount bumped by 15 percent`() = runTest {
|
||||
// Arrange
|
||||
val fromStatus = buildSwapCurrencyStatus(isCoin = false, contractAddress = CONTRACT)
|
||||
val initialFee = Fee.Ethereum.Legacy(
|
||||
amount = ethAmount(BigDecimal("0.002")), // gasLimit * gasPrice / 1e18 = 100_000 * 20e9 / 1e18
|
||||
gasLimit = BigInteger.valueOf(100_000),
|
||||
gasPrice = BigInteger.valueOf(20_000_000_000),
|
||||
)
|
||||
coEvery {
|
||||
getFeeUseCase.invoke(transactionData = any(), userWallet = any(), network = any())
|
||||
} returns TransactionFee.Single(normal = initialFee).right()
|
||||
|
||||
// Act
|
||||
val result = loadLimited(fromStatus)
|
||||
|
||||
// Assert
|
||||
val patched = result.singleNormal<Fee.Ethereum.Legacy>()
|
||||
// gasLimit is NOT changed by this patch (it bumps gas-price only)
|
||||
assertThat(patched.gasLimit).isEqualTo(BigInteger.valueOf(100_000))
|
||||
// 20_000_000_000 * 115 / 100 = 23_000_000_000
|
||||
assertThat(patched.gasPrice).isEqualTo(BigInteger.valueOf(23_000_000_000))
|
||||
// amount recomputed from gasLimit * newGasPrice: 100_000 * 23e9 / 1e18 = 0.0023
|
||||
assertThat(patched.amount.value).isEquivalentAccordingToCompareTo(BigDecimal("0.0023"))
|
||||
// amount decimals preserved
|
||||
assertThat(patched.amount.decimals).isEqualTo(18)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `GIVEN Ethereum EIP1559 Single fee WHEN loaded THEN gas-price fields bumped AND gasLimit untouched`() =
|
||||
runTest {
|
||||
// Arrange
|
||||
val fromStatus = buildSwapCurrencyStatus(isCoin = false, contractAddress = CONTRACT)
|
||||
val initialFee = Fee.Ethereum.EIP1559(
|
||||
amount = ethAmount(BigDecimal("0.0032")), // gasLimit * maxFeePerGas / 1e18 = 80_000 * 40e9 / 1e18
|
||||
gasLimit = BigInteger.valueOf(80_000),
|
||||
maxFeePerGas = BigInteger.valueOf(40_000_000_000),
|
||||
priorityFee = BigInteger.valueOf(2_000_000_000),
|
||||
)
|
||||
coEvery {
|
||||
getFeeUseCase.invoke(transactionData = any(), userWallet = any(), network = any())
|
||||
} returns TransactionFee.Single(normal = initialFee).right()
|
||||
|
||||
// Act
|
||||
val result = loadLimited(fromStatus)
|
||||
|
||||
// Assert
|
||||
val patched = result.singleNormal<Fee.Ethereum.EIP1559>()
|
||||
// gasLimit is NOT changed by this patch (it bumps gas-price only)
|
||||
assertThat(patched.gasLimit).isEqualTo(BigInteger.valueOf(80_000))
|
||||
// EIP1559 gas-price fields scaled by 115 / 100
|
||||
assertThat(patched.maxFeePerGas).isEqualTo(BigInteger.valueOf(46_000_000_000))
|
||||
assertThat(patched.priorityFee).isEqualTo(BigInteger.valueOf(2_300_000_000))
|
||||
// amount recomputed from gasLimit * newMaxFeePerGas: 80_000 * 46e9 / 1e18 = 0.00368
|
||||
assertThat(patched.amount.value).isEquivalentAccordingToCompareTo(BigDecimal("0.00368"))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `GIVEN Choosable Ethereum fee WHEN loaded THEN all three legs gasPrice bumped by 15 percent`() = runTest {
|
||||
// Arrange
|
||||
val fromStatus = buildSwapCurrencyStatus(isCoin = false, contractAddress = CONTRACT)
|
||||
val gasPrice = BigInteger.valueOf(20_000_000_000)
|
||||
val choosable = TransactionFee.Choosable(
|
||||
minimum = Fee.Ethereum.Legacy(
|
||||
amount = ethAmount(BigDecimal("0.0008")), // 40_000 * 20e9 / 1e18
|
||||
gasLimit = BigInteger.valueOf(40_000),
|
||||
gasPrice = gasPrice,
|
||||
),
|
||||
normal = Fee.Ethereum.Legacy(
|
||||
amount = ethAmount(BigDecimal("0.0016")), // 80_000 * 20e9 / 1e18
|
||||
gasLimit = BigInteger.valueOf(80_000),
|
||||
gasPrice = gasPrice,
|
||||
),
|
||||
priority = Fee.Ethereum.Legacy(
|
||||
amount = ethAmount(BigDecimal("0.0024")), // 120_000 * 20e9 / 1e18
|
||||
gasLimit = BigInteger.valueOf(120_000),
|
||||
gasPrice = gasPrice,
|
||||
),
|
||||
)
|
||||
coEvery {
|
||||
getFeeUseCase.invoke(transactionData = any(), userWallet = any(), network = any())
|
||||
} returns choosable.right()
|
||||
|
||||
// Act
|
||||
val patched = (loadLimited(fromStatus).feeOrFail() as TransactionFee.Choosable)
|
||||
|
||||
// Assert — every leg's gas-price scaled (gasPrice * 115 / 100 = 23e9), gasLimit unchanged
|
||||
val newGasPrice = BigInteger.valueOf(23_000_000_000)
|
||||
assertThat((patched.minimum as Fee.Ethereum.Legacy).gasLimit).isEqualTo(BigInteger.valueOf(40_000))
|
||||
assertThat((patched.minimum as Fee.Ethereum.Legacy).gasPrice).isEqualTo(newGasPrice)
|
||||
assertThat((patched.normal as Fee.Ethereum.Legacy).gasLimit).isEqualTo(BigInteger.valueOf(80_000))
|
||||
assertThat((patched.normal as Fee.Ethereum.Legacy).gasPrice).isEqualTo(newGasPrice)
|
||||
assertThat((patched.priority as Fee.Ethereum.Legacy).gasLimit).isEqualTo(BigInteger.valueOf(120_000))
|
||||
assertThat((patched.priority as Fee.Ethereum.Legacy).gasPrice).isEqualTo(newGasPrice)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `GIVEN non-Ethereum approval fee WHEN loaded THEN fee is returned unchanged`() = runTest {
|
||||
// Arrange
|
||||
val fromStatus = buildSwapCurrencyStatus(isCoin = false, contractAddress = CONTRACT)
|
||||
val commonFee = Fee.Common(amount = ethAmount(BigDecimal("0.5"), decimals = 8))
|
||||
coEvery {
|
||||
getFeeUseCase.invoke(transactionData = any(), userWallet = any(), network = any())
|
||||
} returns TransactionFee.Single(normal = commonFee).right()
|
||||
|
||||
// Act
|
||||
val result = loadLimited(fromStatus)
|
||||
|
||||
// Assert — non-Ethereum legs pass through untouched (same instance)
|
||||
assertThat(result.singleNormal<Fee.Common>()).isSameInstanceAs(commonFee)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `GIVEN Ethereum Legacy fee with zero gasLimit WHEN loaded THEN gasPrice bumped and amount is zero`() =
|
||||
runTest {
|
||||
// Arrange
|
||||
val fromStatus = buildSwapCurrencyStatus(isCoin = false, contractAddress = CONTRACT)
|
||||
val zeroGasFee = Fee.Ethereum.Legacy(
|
||||
amount = ethAmount(BigDecimal("0.000002")),
|
||||
gasLimit = BigInteger.ZERO,
|
||||
gasPrice = BigInteger.valueOf(20_000_000_000),
|
||||
)
|
||||
coEvery {
|
||||
getFeeUseCase.invoke(transactionData = any(), userWallet = any(), network = any())
|
||||
} returns TransactionFee.Single(normal = zeroGasFee).right()
|
||||
|
||||
// Act
|
||||
val result = loadLimited(fromStatus)
|
||||
|
||||
// Assert — the gas-price path does NOT short-circuit on zero gasLimit (unlike the
|
||||
// gas-limit path); gasPrice is still bumped and amount recomputes to gasLimit(0) * price = 0
|
||||
val patched = result.singleNormal<Fee.Ethereum.Legacy>()
|
||||
assertThat(patched.gasLimit).isEqualTo(BigInteger.ZERO)
|
||||
assertThat(patched.gasPrice).isEqualTo(BigInteger.valueOf(23_000_000_000))
|
||||
assertThat(patched.amount.value).isEquivalentAccordingToCompareTo(BigDecimal.ZERO)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `GIVEN Ethereum TokenCurrency approval fee WHEN loaded THEN returns Left DataError wrapping [REDACTED_TASK_KEY]`() =
|
||||
runTest {
|
||||
// Arrange
|
||||
val fromStatus = buildSwapCurrencyStatus(isCoin = false, contractAddress = CONTRACT)
|
||||
val tokenFee = Fee.Ethereum.TokenCurrency(
|
||||
amount = ethAmount(BigDecimal("0.001")),
|
||||
gasLimit = BigInteger.valueOf(100_000),
|
||||
coinPriceInToken = BigInteger.ONE,
|
||||
feeTransferGasLimit = BigInteger.valueOf(50_000),
|
||||
baseGas = BigInteger.valueOf(21_000),
|
||||
)
|
||||
coEvery {
|
||||
getFeeUseCase.invoke(transactionData = any(), userWallet = any(), network = any())
|
||||
} returns TransactionFee.Single(normal = tokenFee).right()
|
||||
|
||||
// Act — the patch throws IllegalStateException, but the fee load is wrapped in
|
||||
// runSuspendCatching ([REDACTED_TASK_KEY]) so it is caught and converted to Left(DataError)
|
||||
// instead of crashing the DEX swap flow.
|
||||
val result = loadLimited(fromStatus)
|
||||
|
||||
// Assert
|
||||
assertThat(result.isLeft()).isTrue()
|
||||
result.onLeft { error ->
|
||||
assertThat(error).isInstanceOf(GetFeeError.DataError::class.java)
|
||||
val cause = (error as GetFeeError.DataError).cause
|
||||
assertThat(cause).isInstanceOf(IllegalStateException::class.java)
|
||||
assertThat(cause?.message).contains("[REDACTED_TASK_KEY]")
|
||||
}
|
||||
}
|
||||
|
||||
// endregion
|
||||
|
||||
private suspend fun loadLimited(fromStatus: com.tangem.domain.swap.models.SwapCurrencyStatus) =
|
||||
sut.loadIntegratedApprovalData(
|
||||
fromStatus = fromStatus,
|
||||
spenderAddress = SPENDER,
|
||||
approveType = ApproveType.LIMITED,
|
||||
approvalAmount = SWAP_AMOUNT,
|
||||
)
|
||||
|
||||
/** Unwraps a Right result into its [TransactionFee], failing the test on Left. */
|
||||
private fun arrow.core.Either<GetFeeError, IntegratedApprovalData>.feeOrFail(): TransactionFee {
|
||||
assertThat(isRight()).isTrue()
|
||||
return getOrNull()!!.approvalFee
|
||||
}
|
||||
|
||||
/** Unwraps a Right result into the `normal` leg of a [TransactionFee.Single], cast to [T]. */
|
||||
private inline fun <reified T : Fee> arrow.core.Either<GetFeeError, IntegratedApprovalData>.singleNormal(): T {
|
||||
return (feeOrFail() as TransactionFee.Single).normal as T
|
||||
}
|
||||
|
||||
private fun ethAmount(value: BigDecimal, decimals: Int = 18): Amount = Amount(
|
||||
currencySymbol = "ETH",
|
||||
value = value,
|
||||
decimals = decimals,
|
||||
)
|
||||
|
||||
private companion object {
|
||||
const val SPENDER = "0xSpender"
|
||||
const val CONTRACT = "0xContract"
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -20,6 +20,7 @@ import com.tangem.domain.express.models.ExpressError
|
|||
import com.tangem.domain.models.currency.CryptoCurrency
|
||||
import com.tangem.domain.models.currency.CryptoCurrencyStatus
|
||||
import com.tangem.domain.transaction.error.GetFeeError
|
||||
import com.tangem.domain.transaction.models.AssetRequirementsCondition
|
||||
import com.tangem.domain.transaction.usecase.gasless.IsGaslessFeeSupportedForNetwork
|
||||
import com.tangem.feature.swap.domain.models.ExpressDataError
|
||||
import com.tangem.feature.swap.domain.models.SwapAmount
|
||||
|
|
@ -78,6 +79,19 @@ internal class SwapNotificationsFactory(
|
|||
)
|
||||
}
|
||||
|
||||
fun getDestinationRequirementNotifications(
|
||||
requirement: AssetRequirementsCondition,
|
||||
onAssociateClick: () -> Unit,
|
||||
): ImmutableList<NotificationUM> {
|
||||
val notification = when (requirement) {
|
||||
is AssetRequirementsCondition.RequiredTrustline ->
|
||||
SwapNotificationUM.Warning.TokenTrustlineRequired(onAssociateClick)
|
||||
else ->
|
||||
SwapNotificationUM.Warning.TokenAssociationRequired(onAssociateClick)
|
||||
}
|
||||
return persistentListOf(notification)
|
||||
}
|
||||
|
||||
fun getQuotesErrorStateNotifications(
|
||||
expressDataError: ExpressDataError,
|
||||
fromToken: CryptoCurrency,
|
||||
|
|
|
|||
|
|
@ -49,6 +49,7 @@ internal data class SwapStateHolder(
|
|||
val onShowPermissionBottomSheet: () -> Unit = {},
|
||||
val onSwapUIModeChange: (SwapUIMode) -> Unit = {},
|
||||
val onSwapTypeMenuOpened: () -> Unit = {},
|
||||
val onTronBannerShown: () -> Unit = {},
|
||||
)
|
||||
|
||||
@Immutable
|
||||
|
|
|
|||
|
|
@ -34,4 +34,5 @@ internal data class UiActions(
|
|||
val onReceiveCardWarningClick: () -> Unit,
|
||||
val onSwapUIModeChange: (SwapUIMode) -> Unit,
|
||||
val onSwapTypeMenuOpened: () -> Unit,
|
||||
val onTronBannerShown: () -> Unit,
|
||||
)
|
||||
|
|
@ -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),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
|
@ -35,6 +35,7 @@ import com.tangem.domain.swap.models.PredefinedPercentAmount
|
|||
import com.tangem.domain.swap.models.SwapCurrencyStatus
|
||||
import com.tangem.domain.tokens.model.Amount
|
||||
import com.tangem.domain.transaction.error.GetFeeError
|
||||
import com.tangem.domain.transaction.models.AssetRequirementsCondition
|
||||
import com.tangem.domain.transaction.usecase.gasless.IsGaslessFeeSupportedForNetwork
|
||||
import com.tangem.feature.swap.converters.SwapProviderResolver
|
||||
import com.tangem.feature.swap.converters.SwapProviderStateBuilder
|
||||
|
|
@ -122,6 +123,7 @@ internal class StateBuilder(
|
|||
swapUIMode = swapUIMode,
|
||||
onSwapUIModeChange = actions.onSwapUIModeChange,
|
||||
onSwapTypeMenuOpened = actions.onSwapTypeMenuOpened,
|
||||
onTronBannerShown = actions.onTronBannerShown,
|
||||
shouldShowAbMenu = swapFeatureToggles.isSwapAbEnabled,
|
||||
)
|
||||
}
|
||||
|
|
@ -455,6 +457,34 @@ internal class StateBuilder(
|
|||
uiStateHolder: SwapStateHolder,
|
||||
fromSwapCurrencyStatus: SwapCurrencyStatus,
|
||||
toSwapCurrencyStatus: SwapCurrencyStatus,
|
||||
): SwapStateHolder = createBlockedSwapState(
|
||||
uiStateHolder = uiStateHolder,
|
||||
fromSwapCurrencyStatus = fromSwapCurrencyStatus,
|
||||
toSwapCurrencyStatus = toSwapCurrencyStatus,
|
||||
notifications = notificationsFactory.getSwapNotSupportedNotifications(),
|
||||
)
|
||||
|
||||
fun createDestinationRequirementBlockedState(
|
||||
uiStateHolder: SwapStateHolder,
|
||||
fromSwapCurrencyStatus: SwapCurrencyStatus,
|
||||
toSwapCurrencyStatus: SwapCurrencyStatus,
|
||||
requirement: AssetRequirementsCondition,
|
||||
onAssociateClick: () -> Unit,
|
||||
): SwapStateHolder = createBlockedSwapState(
|
||||
uiStateHolder = uiStateHolder,
|
||||
fromSwapCurrencyStatus = fromSwapCurrencyStatus,
|
||||
toSwapCurrencyStatus = toSwapCurrencyStatus,
|
||||
notifications = notificationsFactory.getDestinationRequirementNotifications(
|
||||
requirement = requirement,
|
||||
onAssociateClick = onAssociateClick,
|
||||
),
|
||||
)
|
||||
|
||||
private fun createBlockedSwapState(
|
||||
uiStateHolder: SwapStateHolder,
|
||||
fromSwapCurrencyStatus: SwapCurrencyStatus,
|
||||
toSwapCurrencyStatus: SwapCurrencyStatus,
|
||||
notifications: ImmutableList<NotificationUM>,
|
||||
): SwapStateHolder {
|
||||
if (uiStateHolder.sendCardData !is SwapCardState.SwapCardData) return uiStateHolder
|
||||
return uiStateHolder.copy(
|
||||
|
|
@ -482,7 +512,7 @@ internal class StateBuilder(
|
|||
isBalanceHidden = isBalanceHiddenProvider(),
|
||||
appCurrency = appCurrencyProvider(),
|
||||
),
|
||||
notifications = notificationsFactory.getSwapNotSupportedNotifications(),
|
||||
notifications = notifications,
|
||||
swapButton = SwapButton(
|
||||
walletInteractionIcon = walletInterationIcon(fromSwapCurrencyStatus.userWallet),
|
||||
isEnabled = false,
|
||||
|
|
|
|||
|
|
@ -94,7 +94,12 @@ internal fun SwapScreenContent(
|
|||
|
||||
feeBlock?.invoke(Modifier.fillMaxWidth())
|
||||
|
||||
if (state.notifications.isNotEmpty()) SwapNotifications(notifications = state.notifications)
|
||||
if (state.notifications.isNotEmpty()) {
|
||||
SwapNotifications(
|
||||
notifications = state.notifications,
|
||||
onTronBannerShown = state.onTronBannerShown,
|
||||
)
|
||||
}
|
||||
|
||||
SpacerHMax()
|
||||
|
||||
|
|
@ -342,7 +347,13 @@ private fun SwapButton(state: SwapStateHolder, modifier: Modifier = Modifier) {
|
|||
|
||||
@Suppress("LongMethod", "CyclomaticComplexMethod")
|
||||
@Composable
|
||||
private fun SwapNotifications(notifications: List<NotificationUM>) {
|
||||
private fun SwapNotifications(notifications: List<NotificationUM>, onTronBannerShown: () -> Unit) {
|
||||
// The Tron token-fee banner's show-count is an "impression": tied to actual on-screen visibility.
|
||||
// LaunchedEffect re-arms only when the boolean flips, so it fires once per hidden -> shown appearance.
|
||||
val isTronBannerShown = notifications.any { it is SwapNotificationUM.Info.TronTokenFee }
|
||||
LaunchedEffect(isTronBannerShown) {
|
||||
if (isTronBannerShown) onTronBannerShown()
|
||||
}
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.background(color = TangemTheme.colors.background.secondary)
|
||||
|
|
|
|||
|
|
@ -6,18 +6,22 @@ import com.tangem.common.ui.notifications.NotificationsFactory.addDustWarningNot
|
|||
import com.tangem.common.ui.notifications.NotificationsFactory.addExceedsBalanceNotification
|
||||
import com.tangem.common.ui.notifications.NotificationsFactory.addExistentialWarningNotification
|
||||
import com.tangem.common.ui.notifications.NotificationsFactory.addFeeCoverageNotification
|
||||
import com.tangem.common.ui.notifications.NotificationsFactory.addFeeUnreachableNotification
|
||||
import com.tangem.common.ui.notifications.NotificationsFactory.addReserveAmountErrorNotification
|
||||
import com.tangem.common.ui.notifications.NotificationsFactory.addTransactionLimitErrorNotification
|
||||
import com.tangem.common.ui.notifications.NotificationsFactory.addValidateTransactionNotifications
|
||||
import com.tangem.core.ui.utils.parseBigDecimal
|
||||
import com.tangem.domain.models.currency.CryptoCurrency
|
||||
import com.tangem.domain.models.currency.CryptoCurrencyStatus
|
||||
import com.tangem.domain.transaction.error.GetFeeError
|
||||
import com.tangem.feature.swap.domain.models.SwapAmount
|
||||
import com.tangem.feature.swap.domain.models.ui.SwapState
|
||||
import com.tangem.feature.swap.models.UiActions
|
||||
import com.tangem.feature.swap.models.states.SwapNotificationUM
|
||||
import com.tangem.lib.crypto.BlockchainUtils
|
||||
import com.tangem.lib.crypto.BlockchainUtils.getTezosThreshold
|
||||
import com.tangem.lib.crypto.BlockchainUtils.isTezos
|
||||
import com.tangem.lib.crypto.BlockchainUtils.isTron
|
||||
import com.tangem.utils.extensions.orZero
|
||||
import kotlinx.collections.immutable.ImmutableList
|
||||
import kotlinx.collections.immutable.toPersistentList
|
||||
|
|
@ -31,9 +35,8 @@ internal class SwapTransferNotificationsFactory @Inject constructor() {
|
|||
transferState: SwapState.Transfer,
|
||||
feeCryptoCurrencyStatus: CryptoCurrencyStatus?,
|
||||
fee: Fee?,
|
||||
onReduceByAmount: (SwapAmount, BigDecimal) -> Unit,
|
||||
onReduceToAmount: (SwapAmount) -> Unit,
|
||||
onBuyClick: (CryptoCurrency) -> Unit,
|
||||
actions: UiActions,
|
||||
getFeeError: GetFeeError?,
|
||||
): ImmutableList<NotificationUM> {
|
||||
return buildList {
|
||||
maybeAddRentExemptionError(transferState)
|
||||
|
|
@ -41,11 +44,21 @@ internal class SwapTransferNotificationsFactory @Inject constructor() {
|
|||
state = transferState,
|
||||
feeCryptoCurrencyStatus = feeCryptoCurrencyStatus,
|
||||
fee = fee,
|
||||
onReduceByAmount = onReduceByAmount,
|
||||
onReduceToAmount = onReduceToAmount,
|
||||
onReduceByAmount = actions.onReduceByAmount,
|
||||
onReduceToAmount = actions.onReduceToAmount,
|
||||
)
|
||||
maybeAddNeedReserveToCreateAccountWarning(transferState)
|
||||
maybeAddExceedsBalanceNotification(transferState, onBuyClick)
|
||||
maybeAddExceedsBalanceNotification(transferState, onBuyClick = actions.openTokenDetailsScreen)
|
||||
addTronNetworkFeesNotification(
|
||||
cryptoCurrencyStatus = transferState.fromTokenInfo.swapCurrencyStatus.status,
|
||||
transferState = transferState,
|
||||
)
|
||||
maybeAddFeeUnreachableNotification(
|
||||
transferState = transferState,
|
||||
feeCryptoCurrencyStatus = feeCryptoCurrencyStatus,
|
||||
feeError = getFeeError,
|
||||
actions = actions,
|
||||
)
|
||||
}.toPersistentList()
|
||||
}
|
||||
|
||||
|
|
@ -194,4 +207,39 @@ internal class SwapTransferNotificationsFactory @Inject constructor() {
|
|||
onResetAnalyticsEvent = {},
|
||||
)
|
||||
}
|
||||
|
||||
private fun MutableList<NotificationUM>.addTronNetworkFeesNotification(
|
||||
cryptoCurrencyStatus: CryptoCurrencyStatus,
|
||||
transferState: SwapState.Transfer,
|
||||
) {
|
||||
val cryptoCurrency = cryptoCurrencyStatus.currency
|
||||
val isTronToken = cryptoCurrency is CryptoCurrency.Token && isTron(cryptoCurrency.network.rawId)
|
||||
val isVisible = isTronToken &&
|
||||
transferState.tronFeeNotificationShowCount <= TRON_FEE_NOTIFICATION_MAX_SHOW_COUNT
|
||||
|
||||
if (isVisible) {
|
||||
add(SwapNotificationUM.Info.TronTokenFee)
|
||||
}
|
||||
}
|
||||
|
||||
private fun MutableList<NotificationUM>.maybeAddFeeUnreachableNotification(
|
||||
transferState: SwapState.Transfer,
|
||||
feeCryptoCurrencyStatus: CryptoCurrencyStatus?,
|
||||
feeError: GetFeeError?,
|
||||
actions: UiActions,
|
||||
) {
|
||||
feeCryptoCurrencyStatus ?: return
|
||||
addFeeUnreachableNotification(
|
||||
tokenStatus = transferState.fromTokenInfo.swapCurrencyStatus.status,
|
||||
coinStatus = feeCryptoCurrencyStatus,
|
||||
feeError = feeError,
|
||||
dustValue = transferState.currencyCheck?.dustValue,
|
||||
onReload = actions.onRetryClick,
|
||||
onClick = actions.openTokenDetailsScreen,
|
||||
)
|
||||
}
|
||||
|
||||
companion object {
|
||||
private const val TRON_FEE_NOTIFICATION_MAX_SHOW_COUNT = 3
|
||||
}
|
||||
}
|
||||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -37,6 +37,7 @@ internal class SwapAmountScreenClickIntentsTest {
|
|||
onReceiveCardWarningClick = {},
|
||||
onSwapUIModeChange = {},
|
||||
onSwapTypeMenuOpened = {},
|
||||
onTronBannerShown = {},
|
||||
)
|
||||
|
||||
private val sut = SwapAmountScreenClickIntents(actions)
|
||||
|
|
|
|||
|
|
@ -12,9 +12,11 @@ import com.tangem.domain.models.wallet.UserWalletId
|
|||
import com.tangem.domain.swap.models.SwapCurrencyStatus
|
||||
import com.tangem.domain.tokens.model.warnings.CryptoCurrencyCheck
|
||||
import com.tangem.domain.tokens.model.warnings.CryptoCurrencyWarning
|
||||
import com.tangem.domain.transaction.error.GetFeeError
|
||||
import com.tangem.feature.swap.domain.models.SwapAmount
|
||||
import com.tangem.feature.swap.domain.models.ui.SwapState
|
||||
import com.tangem.feature.swap.domain.models.ui.TokenSwapInfo
|
||||
import com.tangem.feature.swap.models.UiActions
|
||||
import com.tangem.feature.swap.models.states.SwapNotificationUM
|
||||
import io.mockk.every
|
||||
import io.mockk.mockk
|
||||
|
|
@ -28,6 +30,8 @@ internal class SwapTransferNotificationsFactoryTest {
|
|||
|
||||
private val sut = SwapTransferNotificationsFactory()
|
||||
|
||||
private val actions: UiActions = mockk(relaxed = true)
|
||||
|
||||
private val userWalletId = UserWalletId(stringValue = "deadbeef")
|
||||
private val coldWallet: UserWallet.Cold = mockk(relaxed = true) {
|
||||
every { walletId } returns userWalletId
|
||||
|
|
@ -41,9 +45,8 @@ internal class SwapTransferNotificationsFactoryTest {
|
|||
transferState = transferState,
|
||||
feeCryptoCurrencyStatus = null,
|
||||
fee = null,
|
||||
onReduceByAmount = { _, _ -> },
|
||||
onReduceToAmount = {},
|
||||
onBuyClick = {},
|
||||
actions = actions,
|
||||
getFeeError = null,
|
||||
)
|
||||
|
||||
assertThat(result).isEmpty()
|
||||
|
|
@ -64,9 +67,8 @@ internal class SwapTransferNotificationsFactoryTest {
|
|||
transferState = transferState,
|
||||
feeCryptoCurrencyStatus = null,
|
||||
fee = null,
|
||||
onReduceByAmount = { _, _ -> },
|
||||
onReduceToAmount = {},
|
||||
onBuyClick = {},
|
||||
actions = actions,
|
||||
getFeeError = null,
|
||||
)
|
||||
|
||||
assertThat(result.filterIsInstance<NotificationUM.Solana.RentInfo>()).hasSize(1)
|
||||
|
|
@ -91,9 +93,8 @@ internal class SwapTransferNotificationsFactoryTest {
|
|||
transferState = transferState,
|
||||
feeCryptoCurrencyStatus = null,
|
||||
fee = fee,
|
||||
onReduceByAmount = { _, _ -> },
|
||||
onReduceToAmount = {},
|
||||
onBuyClick = {},
|
||||
actions = actions,
|
||||
getFeeError = null,
|
||||
)
|
||||
|
||||
assertThat(result.filterIsInstance<NotificationUM.Error.ExistentialDeposit>()).hasSize(1)
|
||||
|
|
@ -114,9 +115,8 @@ internal class SwapTransferNotificationsFactoryTest {
|
|||
transferState = transferState,
|
||||
feeCryptoCurrencyStatus = null,
|
||||
fee = null,
|
||||
onReduceByAmount = { _, _ -> },
|
||||
onReduceToAmount = {},
|
||||
onBuyClick = {},
|
||||
actions = actions,
|
||||
getFeeError = null,
|
||||
)
|
||||
|
||||
assertThat(result.filterIsInstance<NotificationUM.Error.MinimumAmountError>()).hasSize(1)
|
||||
|
|
@ -133,9 +133,8 @@ internal class SwapTransferNotificationsFactoryTest {
|
|||
transferState = transferState,
|
||||
feeCryptoCurrencyStatus = null,
|
||||
fee = null,
|
||||
onReduceByAmount = { _, _ -> },
|
||||
onReduceToAmount = {},
|
||||
onBuyClick = {},
|
||||
actions = actions,
|
||||
getFeeError = null,
|
||||
)
|
||||
|
||||
assertThat(result.filterIsInstance<NotificationUM.Cardano.MinAdaValueCharged>()).hasSize(1)
|
||||
|
|
@ -157,9 +156,8 @@ internal class SwapTransferNotificationsFactoryTest {
|
|||
transferState = transferState,
|
||||
feeCryptoCurrencyStatus = null,
|
||||
fee = null,
|
||||
onReduceByAmount = { _, _ -> },
|
||||
onReduceToAmount = {},
|
||||
onBuyClick = {},
|
||||
actions = actions,
|
||||
getFeeError = null,
|
||||
)
|
||||
|
||||
assertThat(result.filterIsInstance<NotificationUM.Warning.FeeCoverageNotification>()).hasSize(1)
|
||||
|
|
@ -180,9 +178,8 @@ internal class SwapTransferNotificationsFactoryTest {
|
|||
transferState = transferState,
|
||||
feeCryptoCurrencyStatus = null,
|
||||
fee = null,
|
||||
onReduceByAmount = { _, _ -> },
|
||||
onReduceToAmount = {},
|
||||
onBuyClick = {},
|
||||
actions = actions,
|
||||
getFeeError = null,
|
||||
)
|
||||
|
||||
val reserve = result.filterIsInstance<SwapNotificationUM.Warning.NeedReserveToCreateAccount>()
|
||||
|
|
@ -204,9 +201,8 @@ internal class SwapTransferNotificationsFactoryTest {
|
|||
transferState = transferState,
|
||||
feeCryptoCurrencyStatus = null,
|
||||
fee = null,
|
||||
onReduceByAmount = { _, _ -> },
|
||||
onReduceToAmount = {},
|
||||
onBuyClick = {},
|
||||
actions = actions,
|
||||
getFeeError = null,
|
||||
)
|
||||
|
||||
assertThat(result.filterIsInstance<SwapNotificationUM.Warning.ReduceAmount>()).hasSize(1)
|
||||
|
|
@ -226,14 +222,146 @@ internal class SwapTransferNotificationsFactoryTest {
|
|||
transferState = transferState,
|
||||
feeCryptoCurrencyStatus = null,
|
||||
fee = null,
|
||||
onReduceByAmount = { _, _ -> },
|
||||
onReduceToAmount = {},
|
||||
onBuyClick = {},
|
||||
actions = actions,
|
||||
getFeeError = null,
|
||||
)
|
||||
|
||||
assertThat(result.filterIsInstance<NotificationUM.Error.TokenExceedsBalance>()).hasSize(1)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `GIVEN Tron token and show count within limit WHEN getNotifications THEN Tron network fees Info is added`() =
|
||||
runTest {
|
||||
val transferState = buildTransferState(
|
||||
fromTokenInfo = buildTokenInfo(
|
||||
swapCurrencyStatus = buildTronTokenStatus(),
|
||||
amount = BigDecimal("10"),
|
||||
),
|
||||
tronFeeNotificationShowCount = TRON_FEE_NOTIFICATION_MAX_SHOW_COUNT,
|
||||
)
|
||||
|
||||
val result = sut.getNotifications(
|
||||
transferState = transferState,
|
||||
feeCryptoCurrencyStatus = null,
|
||||
fee = null,
|
||||
actions = actions,
|
||||
getFeeError = null,
|
||||
)
|
||||
|
||||
assertThat(result.filterIsInstance<SwapNotificationUM.Info.TronTokenFee>()).hasSize(1)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `GIVEN Tron token but show count exceeds limit WHEN getNotifications THEN no Tron network fees Info`() =
|
||||
runTest {
|
||||
val transferState = buildTransferState(
|
||||
fromTokenInfo = buildTokenInfo(
|
||||
swapCurrencyStatus = buildTronTokenStatus(),
|
||||
amount = BigDecimal("10"),
|
||||
),
|
||||
tronFeeNotificationShowCount = TRON_FEE_NOTIFICATION_MAX_SHOW_COUNT + 1,
|
||||
)
|
||||
|
||||
val result = sut.getNotifications(
|
||||
transferState = transferState,
|
||||
feeCryptoCurrencyStatus = null,
|
||||
fee = null,
|
||||
actions = actions,
|
||||
getFeeError = null,
|
||||
)
|
||||
|
||||
assertThat(result.filterIsInstance<SwapNotificationUM.Info.TronTokenFee>()).isEmpty()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `GIVEN Tron coin (not token) WHEN getNotifications THEN no Tron network fees Info`() = runTest {
|
||||
val transferState = buildTransferState(
|
||||
fromTokenInfo = buildTokenInfo(
|
||||
swapCurrencyStatus = buildCoinStatus(rawNetworkId = "tron"),
|
||||
amount = BigDecimal("10"),
|
||||
),
|
||||
tronFeeNotificationShowCount = 0,
|
||||
)
|
||||
|
||||
val result = sut.getNotifications(
|
||||
transferState = transferState,
|
||||
feeCryptoCurrencyStatus = null,
|
||||
fee = null,
|
||||
actions = actions,
|
||||
getFeeError = null,
|
||||
)
|
||||
|
||||
assertThat(result.filterIsInstance<SwapNotificationUM.Info.TronTokenFee>()).isEmpty()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `GIVEN UnknownError fee error and fee currency status WHEN getNotifications THEN NetworkFeeUnreachable is added`() =
|
||||
runTest {
|
||||
val transferState = buildTransferState()
|
||||
val feeCryptoCurrencyStatus = buildCoinStatus().status
|
||||
|
||||
val result = sut.getNotifications(
|
||||
transferState = transferState,
|
||||
feeCryptoCurrencyStatus = feeCryptoCurrencyStatus,
|
||||
fee = null,
|
||||
actions = actions,
|
||||
getFeeError = GetFeeError.UnknownError,
|
||||
)
|
||||
|
||||
assertThat(result.filterIsInstance<NotificationUM.Warning.NetworkFeeUnreachable>()).hasSize(1)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `GIVEN TronActivationError fee error and fee currency status WHEN getNotifications THEN TronAccountNotActivated is added`() =
|
||||
runTest {
|
||||
val transferState = buildTransferState()
|
||||
val feeCryptoCurrencyStatus = buildCoinStatus().status
|
||||
|
||||
val result = sut.getNotifications(
|
||||
transferState = transferState,
|
||||
feeCryptoCurrencyStatus = feeCryptoCurrencyStatus,
|
||||
fee = null,
|
||||
actions = actions,
|
||||
getFeeError = GetFeeError.BlockchainErrors.TronActivationError,
|
||||
)
|
||||
|
||||
val notifications = result.filterIsInstance<NotificationUM.Warning.TronAccountNotActivated>()
|
||||
assertThat(notifications).hasSize(1)
|
||||
assertThat(notifications.first().tokenName).isEqualTo(feeCryptoCurrencyStatus.currency.name)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `GIVEN fee error but null fee currency status WHEN getNotifications THEN no fee unreachable notification`() =
|
||||
runTest {
|
||||
val transferState = buildTransferState()
|
||||
|
||||
val result = sut.getNotifications(
|
||||
transferState = transferState,
|
||||
feeCryptoCurrencyStatus = null,
|
||||
fee = null,
|
||||
actions = actions,
|
||||
getFeeError = GetFeeError.UnknownError,
|
||||
)
|
||||
|
||||
assertThat(result.filterIsInstance<NotificationUM.Warning.NetworkFeeUnreachable>()).isEmpty()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `GIVEN null fee error and fee currency status WHEN getNotifications THEN no fee unreachable notification`() =
|
||||
runTest {
|
||||
val transferState = buildTransferState()
|
||||
|
||||
val result = sut.getNotifications(
|
||||
transferState = transferState,
|
||||
feeCryptoCurrencyStatus = buildCoinStatus().status,
|
||||
fee = null,
|
||||
actions = actions,
|
||||
getFeeError = null,
|
||||
)
|
||||
|
||||
assertThat(result.filterIsInstance<NotificationUM.Warning.NetworkFeeUnreachable>()).isEmpty()
|
||||
}
|
||||
|
||||
@Suppress("LongParameterList")
|
||||
private fun buildTransferState(
|
||||
fromTokenInfo: TokenSwapInfo = buildTokenInfo(buildCoinStatus()),
|
||||
|
|
@ -244,6 +372,7 @@ internal class SwapTransferNotificationsFactoryTest {
|
|||
minAdaValue: BigDecimal? = null,
|
||||
isFeeCoverage: Boolean = false,
|
||||
sendingAmount: BigDecimal = fromTokenInfo.tokenAmount.value,
|
||||
tronFeeNotificationShowCount: Int = 0,
|
||||
): SwapState.Transfer = SwapState.Transfer(
|
||||
userWallet = coldWallet,
|
||||
fromTokenInfo = fromTokenInfo,
|
||||
|
|
@ -255,6 +384,7 @@ internal class SwapTransferNotificationsFactoryTest {
|
|||
isAccountsMode = false,
|
||||
isFeeCoverage = isFeeCoverage,
|
||||
sendingAmount = sendingAmount,
|
||||
tronFeeNotificationShowCount = tronFeeNotificationShowCount,
|
||||
currencyCheck = currencyCheck,
|
||||
validationResult = validationResult,
|
||||
minAdaValue = minAdaValue,
|
||||
|
|
@ -326,4 +456,31 @@ internal class SwapTransferNotificationsFactoryTest {
|
|||
every { decimals } returns 18
|
||||
}
|
||||
}
|
||||
|
||||
private fun buildTronTokenStatus(): SwapCurrencyStatus {
|
||||
val token = mockk<CryptoCurrency.Token>(relaxed = true) {
|
||||
every { id } returns mockk(relaxed = true)
|
||||
every { network } returns mockk(relaxed = true) {
|
||||
every { rawId } returns "tron"
|
||||
every { name } returns "Tron"
|
||||
}
|
||||
every { name } returns "Tether"
|
||||
every { symbol } returns "USDT"
|
||||
every { decimals } returns 6
|
||||
}
|
||||
val statusValue: CryptoCurrencyStatus.Loaded = mockk(relaxed = true) {
|
||||
every { amount } returns BigDecimal("100")
|
||||
every { fiatRate } returns BigDecimal.ONE
|
||||
every { fiatAmount } returns BigDecimal("100")
|
||||
}
|
||||
return SwapCurrencyStatus(
|
||||
userWallet = coldWallet,
|
||||
status = CryptoCurrencyStatus(currency = token, value = statusValue),
|
||||
account = Account.CryptoPortfolio.createMainAccount(userWalletId),
|
||||
)
|
||||
}
|
||||
|
||||
private companion object {
|
||||
const val TRON_FEE_NOTIFICATION_MAX_SHOW_COUNT = 3
|
||||
}
|
||||
}
|
||||
|
|
@ -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,
|
||||
)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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)))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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() {
|
||||
|
|
|
|||
|
|
@ -77,6 +77,7 @@ internal class TangemPayCardDetailsController @AssistedInject constructor(
|
|||
onReveal = ::requestReveal,
|
||||
onCopy = ::copyData,
|
||||
shouldShowCardDetailsButtonOnCard = config.shouldShowCardDetailsButtonOnCard,
|
||||
cardState = card.state,
|
||||
)
|
||||
|
||||
val uiState: StateFlow<TangemPayCardDetailsUM>
|
||||
|
|
@ -117,6 +118,7 @@ internal class TangemPayCardDetailsController @AssistedInject constructor(
|
|||
numberShort = "${StringsSigns.ASTERISK}${card.lastDigits}",
|
||||
cardFrozenState = card.frozenState,
|
||||
isActionsAvailable = card.state == TangemPayCardState.Active,
|
||||
cardState = card.state,
|
||||
)
|
||||
}
|
||||
subscribeToCardFrozenState(card.id)
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
)
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -0,0 +1,167 @@
|
|||
package com.tangem.features.tangempay.model
|
||||
|
||||
import androidx.compose.ui.text.input.TextFieldValue
|
||||
import arrow.core.right
|
||||
import com.google.common.truth.Truth.assertThat
|
||||
import com.tangem.core.decompose.model.MutableParamsContainer
|
||||
import com.tangem.core.decompose.navigation.Router
|
||||
import com.tangem.core.decompose.ui.UiMessageSender
|
||||
import com.tangem.core.ui.message.DialogMessage
|
||||
import com.tangem.domain.models.account.CardDisplayName
|
||||
import com.tangem.domain.models.pay.TangemPayCard
|
||||
import com.tangem.domain.models.pay.TangemPayCardFrozenState
|
||||
import com.tangem.domain.models.pay.TangemPayCardState
|
||||
import com.tangem.domain.models.wallet.UserWalletId
|
||||
import com.tangem.domain.pay.flow.PaymentAccountStatusSupplier
|
||||
import com.tangem.domain.pay.usecase.UpdateTangemPayCardNameUseCase
|
||||
import com.tangem.features.tangempay.TangemPayFeatureToggles
|
||||
import com.tangem.features.tangempay.components.TangemPayEditDisplayNameComponent
|
||||
import com.tangem.features.tangempay.model.controller.TangemPayCardDetailsController
|
||||
import com.tangem.utils.coroutines.TestingCoroutineDispatcherProvider
|
||||
import io.mockk.coEvery
|
||||
import io.mockk.coVerify
|
||||
import io.mockk.every
|
||||
import io.mockk.mockk
|
||||
import io.mockk.verify
|
||||
import kotlinx.coroutines.flow.emptyFlow
|
||||
import org.junit.jupiter.api.Nested
|
||||
import org.junit.jupiter.api.Test
|
||||
|
||||
internal class TangemPayEditDisplayNameModelTest {
|
||||
|
||||
private val cardId = "card_1"
|
||||
private val userWalletId = UserWalletId("123")
|
||||
|
||||
private val router: Router = mockk(relaxed = true)
|
||||
private val updateCardNameUseCase: UpdateTangemPayCardNameUseCase = mockk()
|
||||
private val uiMessageSender: UiMessageSender = mockk(relaxed = true)
|
||||
private val paymentAccountStatusSupplier: PaymentAccountStatusSupplier = mockk()
|
||||
private val featureToggles: TangemPayFeatureToggles = mockk(relaxed = true)
|
||||
private val cardDetailsControllerFactory: TangemPayCardDetailsController.Factory = mockk(relaxed = true)
|
||||
|
||||
init {
|
||||
every { paymentAccountStatusSupplier.invoke(any<UserWalletId>()) } returns emptyFlow()
|
||||
}
|
||||
|
||||
private fun createModel(displayName: CardDisplayName? = null) = TangemPayEditDisplayNameModel(
|
||||
paramsContainer = MutableParamsContainer(
|
||||
TangemPayEditDisplayNameComponent.Params(
|
||||
card = card(displayName = displayName),
|
||||
userWalletId = userWalletId,
|
||||
),
|
||||
),
|
||||
dispatchers = TestingCoroutineDispatcherProvider(),
|
||||
router = router,
|
||||
updateCardNameUseCase = updateCardNameUseCase,
|
||||
uiMessageSender = uiMessageSender,
|
||||
paymentAccountStatusSupplier = paymentAccountStatusSupplier,
|
||||
featureToggles = featureToggles,
|
||||
cardDetailsControllerFactory = cardDetailsControllerFactory,
|
||||
)
|
||||
|
||||
@Nested
|
||||
inner class OnValueChanged {
|
||||
|
||||
// [REDACTED_TASK_KEY]: invalid-but-present input must NOT disable the button, otherwise the
|
||||
// user can never press Done to see the "Invalid characters" alert.
|
||||
@Test
|
||||
fun `GIVEN emoji input WHEN onValueChanged THEN button stays enabled`() {
|
||||
val model = createModel()
|
||||
|
||||
model.uiState.value.onValueChanged(TextFieldValue("Card 😀"))
|
||||
|
||||
assertThat(model.uiState.value.isDoneEnabled).isTrue()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `GIVEN special characters input WHEN onValueChanged THEN button stays enabled`() {
|
||||
val model = createModel()
|
||||
|
||||
model.uiState.value.onValueChanged(TextFieldValue("Card #1!"))
|
||||
|
||||
assertThat(model.uiState.value.isDoneEnabled).isTrue()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `GIVEN valid input WHEN onValueChanged THEN button enabled`() {
|
||||
val model = createModel()
|
||||
|
||||
model.uiState.value.onValueChanged(TextFieldValue("My Card"))
|
||||
|
||||
assertThat(model.uiState.value.isDoneEnabled).isTrue()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `GIVEN blank input WHEN onValueChanged THEN button disabled`() {
|
||||
val model = createModel()
|
||||
|
||||
model.uiState.value.onValueChanged(TextFieldValue(" "))
|
||||
|
||||
assertThat(model.uiState.value.isDoneEnabled).isFalse()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `GIVEN input longer than max length WHEN onValueChanged THEN change is ignored`() {
|
||||
val model = createModel()
|
||||
val maxLengthText = "a".repeat(CardDisplayName.MAX_LENGTH)
|
||||
model.uiState.value.onValueChanged(TextFieldValue(maxLengthText))
|
||||
|
||||
model.uiState.value.onValueChanged(TextFieldValue(maxLengthText + "b"))
|
||||
|
||||
assertThat(model.uiState.value.editingValue.text).isEqualTo(maxLengthText)
|
||||
}
|
||||
}
|
||||
|
||||
@Nested
|
||||
inner class OnDoneClick {
|
||||
|
||||
// [REDACTED_TASK_KEY]: pressing Done on an invalid name surfaces the "Invalid characters" alert
|
||||
// and does not persist the name.
|
||||
@Test
|
||||
fun `GIVEN invalid name WHEN onDoneClick THEN error dialog shown and name not updated`() {
|
||||
val model = createModel()
|
||||
model.uiState.value.onValueChanged(TextFieldValue("Card 😀"))
|
||||
|
||||
model.uiState.value.onDoneClick()
|
||||
|
||||
verify(exactly = 1) { uiMessageSender.send(any<DialogMessage>()) }
|
||||
coVerify(exactly = 0) { updateCardNameUseCase(any(), any(), any()) }
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `GIVEN valid changed name WHEN onDoneClick THEN name updated`() {
|
||||
coEvery { updateCardNameUseCase(any(), any(), any()) } returns Unit.right()
|
||||
val model = createModel()
|
||||
model.uiState.value.onValueChanged(TextFieldValue("New Name"))
|
||||
|
||||
model.uiState.value.onDoneClick()
|
||||
|
||||
coVerify(exactly = 1) {
|
||||
updateCardNameUseCase(cardId, userWalletId, CardDisplayName("New Name").getOrNull()!!)
|
||||
}
|
||||
verify(exactly = 0) { uiMessageSender.send(any<DialogMessage>()) }
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `GIVEN unchanged name WHEN onDoneClick THEN screen closed without update`() {
|
||||
val model = createModel(displayName = CardDisplayName("My Card").getOrNull())
|
||||
|
||||
model.uiState.value.onDoneClick()
|
||||
|
||||
verify(exactly = 1) { router.pop() }
|
||||
coVerify(exactly = 0) { updateCardNameUseCase(any(), any(), any()) }
|
||||
}
|
||||
}
|
||||
|
||||
private fun card(displayName: CardDisplayName? = null) = TangemPayCard(
|
||||
id = cardId,
|
||||
productInstanceId = "product",
|
||||
cardStatus = TangemPayCard.Status.ACTIVE,
|
||||
hasPinCode = true,
|
||||
displayName = displayName,
|
||||
limit = null,
|
||||
frozenState = TangemPayCardFrozenState.Unfrozen,
|
||||
lastDigits = "1234",
|
||||
state = TangemPayCardState.Active,
|
||||
)
|
||||
}
|
||||
|
|
@ -3,7 +3,10 @@ package com.tangem.feature.wallet.presentation.wallet.domain
|
|||
import com.tangem.common.TangemSiteUrlBuilder
|
||||
import com.tangem.common.ui.notifications.NotificationId
|
||||
import com.tangem.core.decompose.di.ModelScoped
|
||||
import com.tangem.domain.account.status.producer.SingleAccountStatusListProducer
|
||||
import com.tangem.domain.account.status.supplier.SingleAccountStatusListSupplier
|
||||
import com.tangem.domain.card.common.util.cardTypesResolver
|
||||
import com.tangem.domain.models.TotalFiatBalance
|
||||
import com.tangem.domain.models.wallet.UserWallet
|
||||
import com.tangem.domain.notifications.repository.NotificationsRepository
|
||||
import com.tangem.domain.settings.IsReadyToShowRateAppUseCase
|
||||
|
|
@ -20,12 +23,14 @@ import kotlinx.coroutines.flow.Flow
|
|||
import kotlinx.coroutines.flow.combine
|
||||
import kotlinx.coroutines.flow.conflate
|
||||
import kotlinx.coroutines.flow.distinctUntilChanged
|
||||
import kotlinx.coroutines.flow.map
|
||||
import javax.inject.Inject
|
||||
|
||||
/**
|
||||
* Factory for creating a list of notifications that can be shown on the wallet screen.
|
||||
* These notifications are not critical and can be stacked with each other.
|
||||
*/
|
||||
@Suppress("LongParameterList")
|
||||
@ModelScoped
|
||||
internal class GetWalletNotificationsCarouselFactory @Inject constructor(
|
||||
private val isReadyToShowRateAppUseCase: IsReadyToShowRateAppUseCase,
|
||||
|
|
@ -34,8 +39,15 @@ internal class GetWalletNotificationsCarouselFactory @Inject constructor(
|
|||
private val shouldShowYieldBoostMainBannerUseCase: ShouldShowYieldBoostMainBannerUseCase,
|
||||
private val yieldSupplyGetShouldShowMainPromoUseCase: YieldSupplyGetShouldShowMainPromoUseCase,
|
||||
private val yieldSupplyFeatureToggles: YieldSupplyFeatureToggles,
|
||||
private val singleAccountStatusListSupplier: SingleAccountStatusListSupplier,
|
||||
) {
|
||||
fun create(userWallet: UserWallet, clickIntents: WalletClickIntents): Flow<ImmutableList<WalletNotificationUM>> {
|
||||
val isBalanceResolvedFlow = singleAccountStatusListSupplier(
|
||||
SingleAccountStatusListProducer.Params(userWallet.walletId),
|
||||
)
|
||||
.map { it.totalFiatBalance !is TotalFiatBalance.Loading }
|
||||
.distinctUntilChanged()
|
||||
|
||||
return combine(
|
||||
flow = notificationsRepository.getShouldShowNotification(
|
||||
NotificationId.EnablePushesReminderNotification.key,
|
||||
|
|
@ -43,11 +55,15 @@ internal class GetWalletNotificationsCarouselFactory @Inject constructor(
|
|||
flow2 = isReadyToShowRateAppUseCase().distinctUntilChanged(),
|
||||
flow3 = getWalletsUseCase().conflate(),
|
||||
flow4 = yieldSupplyGetShouldShowMainPromoUseCase().distinctUntilChanged(),
|
||||
) { showPushesNotification, showRateAppPromo, wallets, shouldShowYieldPromoLocal ->
|
||||
flow5 = isBalanceResolvedFlow,
|
||||
) { showPushesNotification, showRateAppPromo, wallets, shouldShowYieldPromoLocal, isBalanceResolved ->
|
||||
|
||||
buildList {
|
||||
addNoteMigrationNotification(userWallet, wallets, clickIntents)
|
||||
addRateAppNotification(showRateAppPromo, clickIntents)
|
||||
|
||||
// isBalanceResolved gates Rate App on the balance leaving the loading state, so it does not
|
||||
// flash during loading and then get replaced once balance-dependent banners are resolved.
|
||||
addRateAppNotification(showRateAppPromo && isBalanceResolved, clickIntents)
|
||||
|
||||
addPushNotification(
|
||||
shouldShow = showPushesNotification,
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
|
|
|
|||
|
|
@ -2,6 +2,13 @@ package com.tangem.feature.wallet.presentation.wallet.domain
|
|||
|
||||
import arrow.core.Either
|
||||
import com.google.common.truth.Truth.assertThat
|
||||
import com.tangem.domain.account.models.AccountStatusList
|
||||
import com.tangem.domain.account.status.producer.SingleAccountStatusListProducer
|
||||
import com.tangem.domain.account.status.supplier.SingleAccountStatusListSupplier
|
||||
import com.tangem.domain.models.StatusSource
|
||||
import com.tangem.domain.models.TokensGroupType
|
||||
import com.tangem.domain.models.TokensSortType
|
||||
import com.tangem.domain.models.TotalFiatBalance
|
||||
import com.tangem.domain.models.wallet.UserWallet
|
||||
import com.tangem.domain.models.wallet.UserWalletId
|
||||
import com.tangem.domain.notifications.repository.NotificationsRepository
|
||||
|
|
@ -25,6 +32,7 @@ import org.junit.jupiter.api.Test
|
|||
import org.junit.jupiter.api.TestInstance
|
||||
import org.junit.jupiter.params.ParameterizedTest
|
||||
import org.junit.jupiter.params.provider.MethodSource
|
||||
import java.math.BigDecimal
|
||||
|
||||
@TestInstance(TestInstance.Lifecycle.PER_CLASS)
|
||||
internal class GetWalletNotificationsCarouselFactoryTest {
|
||||
|
|
@ -35,6 +43,7 @@ internal class GetWalletNotificationsCarouselFactoryTest {
|
|||
private val shouldShowYieldBoostMainBannerUseCase: ShouldShowYieldBoostMainBannerUseCase = mockk()
|
||||
private val yieldSupplyGetShouldShowMainPromoUseCase: YieldSupplyGetShouldShowMainPromoUseCase = mockk()
|
||||
private val yieldSupplyFeatureToggles: YieldSupplyFeatureToggles = mockk()
|
||||
private val singleAccountStatusListSupplier: SingleAccountStatusListSupplier = mockk(relaxed = true)
|
||||
private val clickIntents: WalletClickIntents = mockk(relaxed = true)
|
||||
private val userWallet: UserWallet.Hot = mockk(relaxed = true)
|
||||
|
||||
|
|
@ -45,6 +54,7 @@ internal class GetWalletNotificationsCarouselFactoryTest {
|
|||
shouldShowYieldBoostMainBannerUseCase = shouldShowYieldBoostMainBannerUseCase,
|
||||
yieldSupplyGetShouldShowMainPromoUseCase = yieldSupplyGetShouldShowMainPromoUseCase,
|
||||
yieldSupplyFeatureToggles = yieldSupplyFeatureToggles,
|
||||
singleAccountStatusListSupplier = singleAccountStatusListSupplier,
|
||||
)
|
||||
|
||||
@BeforeEach
|
||||
|
|
@ -56,6 +66,7 @@ internal class GetWalletNotificationsCarouselFactoryTest {
|
|||
shouldShowYieldBoostMainBannerUseCase,
|
||||
yieldSupplyGetShouldShowMainPromoUseCase,
|
||||
yieldSupplyFeatureToggles,
|
||||
singleAccountStatusListSupplier,
|
||||
clickIntents,
|
||||
userWallet,
|
||||
)
|
||||
|
|
@ -68,6 +79,10 @@ internal class GetWalletNotificationsCarouselFactoryTest {
|
|||
every { yieldSupplyGetShouldShowMainPromoUseCase() } returns flowOf(true)
|
||||
every { yieldSupplyFeatureToggles.isYieldPromoEnabled } returns true
|
||||
coEvery { shouldShowYieldBoostMainBannerUseCase(any()) } returns Either.Right(true)
|
||||
// Balance is loaded by default, so banners gated on balance are not suppressed.
|
||||
every {
|
||||
singleAccountStatusListSupplier(any<SingleAccountStatusListProducer.Params>())
|
||||
} returns flowOf(accountStatusList(TotalFiatBalance.Loaded(BigDecimal.ZERO, StatusSource.ACTUAL)))
|
||||
}
|
||||
|
||||
@ParameterizedTest
|
||||
|
|
@ -85,6 +100,24 @@ internal class GetWalletNotificationsCarouselFactoryTest {
|
|||
assertThat(result.any { it is WalletNotificationUM.YieldBoostPromo }).isEqualTo(model.expectedShown)
|
||||
}
|
||||
|
||||
@ParameterizedTest
|
||||
@MethodSource("provideRateAppTestModels")
|
||||
fun `GIVEN ready to show rate app and balance state WHEN create THEN rate app banner visibility matches`(
|
||||
model: RateAppModel,
|
||||
) = runTest {
|
||||
// Arrange
|
||||
every { isReadyToShowRateAppUseCase() } returns flowOf(model.isReadyToShow)
|
||||
every {
|
||||
singleAccountStatusListSupplier(any<SingleAccountStatusListProducer.Params>())
|
||||
} returns flowOf(accountStatusList(model.balance))
|
||||
|
||||
// Act
|
||||
val result = factory.create(userWallet, clickIntents).first()
|
||||
|
||||
// Assert
|
||||
assertThat(result.any { it is WalletNotificationUM.RateApp }).isEqualTo(model.expectedShown)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `GIVEN banner shown WHEN buttons clicked THEN routes to click intents`() = runTest {
|
||||
// Arrange
|
||||
|
|
@ -101,6 +134,16 @@ internal class GetWalletNotificationsCarouselFactoryTest {
|
|||
verify { clickIntents.onDismissYieldBoostBanner(WALLET_ID) }
|
||||
}
|
||||
|
||||
private fun accountStatusList(balance: TotalFiatBalance) = AccountStatusList(
|
||||
userWalletId = WALLET_ID,
|
||||
accountStatuses = emptyList(),
|
||||
totalAccounts = 0,
|
||||
totalArchivedAccounts = 0,
|
||||
totalFiatBalance = balance,
|
||||
sortType = TokensSortType.NONE,
|
||||
groupType = TokensGroupType.NONE,
|
||||
)
|
||||
|
||||
internal data class Model(
|
||||
val toggleEnabled: Boolean,
|
||||
val shouldShowLocal: Boolean,
|
||||
|
|
@ -121,6 +164,31 @@ internal class GetWalletNotificationsCarouselFactoryTest {
|
|||
),
|
||||
)
|
||||
|
||||
internal data class RateAppModel(
|
||||
val isReadyToShow: Boolean,
|
||||
val balance: TotalFiatBalance,
|
||||
val expectedShown: Boolean,
|
||||
)
|
||||
|
||||
private fun provideRateAppTestModels() = listOf(
|
||||
// Ready to show, but the balance is still loading — don't flash before Add Funds may appear.
|
||||
RateAppModel(isReadyToShow = true, balance = TotalFiatBalance.Loading, expectedShown = false),
|
||||
// Ready to show and the balance is loaded — the banner can appear.
|
||||
RateAppModel(
|
||||
isReadyToShow = true,
|
||||
balance = TotalFiatBalance.Loaded(BigDecimal.ZERO, StatusSource.ACTUAL),
|
||||
expectedShown = true,
|
||||
),
|
||||
// Ready to show and the balance failed — terminal state, only loading suppresses the banner.
|
||||
RateAppModel(isReadyToShow = true, balance = TotalFiatBalance.Failed, expectedShown = true),
|
||||
// Not ready to show — the banner stays hidden regardless of the balance state.
|
||||
RateAppModel(
|
||||
isReadyToShow = false,
|
||||
balance = TotalFiatBalance.Loaded(BigDecimal.ZERO, StatusSource.ACTUAL),
|
||||
expectedShown = false,
|
||||
),
|
||||
)
|
||||
|
||||
private companion object {
|
||||
val WALLET_ID = UserWalletId("01")
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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]")
|
||||
}
|
||||
}
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue