Updated on 2026-08-14

This commit is contained in:
Tangem 2026-05-29 16:41:30 +02:00
commit 383d42f6b3
113 changed files with 2380 additions and 649 deletions

View file

@ -186,6 +186,7 @@ abstract class BaseTestCase : TestCase(
"VISA_ONBOARDING_ENABLED" to true,
"AND_15101_TANGEM_PAY_HOT_WALLET_ONBOARDING" to true,
"AND_15310_ADD_FUNDS_STAGE1" to true,
"APP_REDESIGN_ENABLED" to true,
)
)
}

View file

@ -115,5 +115,13 @@ private fun extractText(node: SemanticsNode): String? {
private fun parseVolume(node: SemanticsNode): Double? {
val text = extractText(node) ?: return null
return text.replace("[^0-9.]".toRegex(), "").toDoubleOrNull()
val multiplier = when {
text.contains('T', ignoreCase = true) -> 1_000_000_000_000.0
text.contains('B', ignoreCase = true) -> 1_000_000_000.0
text.contains('M', ignoreCase = true) -> 1_000_000.0
text.contains('K', ignoreCase = true) -> 1_000.0
else -> 1.0
}
val number = text.replace("[^0-9.]".toRegex(), "").toDoubleOrNull() ?: return null
return number * multiplier
}

View file

@ -11,6 +11,11 @@ fun KNode.clickWithAssertion() {
performClick()
}
fun KNode.clickWhenEnabled() {
assertIsEnabled()
performClick()
}
fun KNode.assertTextContainsSafe(
text: String,
substring: Boolean = false,

View file

@ -4,8 +4,6 @@ import androidx.test.uiautomator.By
import androidx.test.uiautomator.Until
import com.tangem.common.BaseTestCase
import com.tangem.common.constants.TestConstants.WAIT_UNTIL_TIMEOUT_LONG
import com.tangem.wallet.R
import io.github.kakaocup.kakao.common.utilities.getResourceString
fun BaseTestCase.swipeVertical(
direction: SwipeDirection,
@ -31,21 +29,6 @@ fun BaseTestCase.pullToRefresh(steps: Int = 1000) {
)
}
fun BaseTestCase.swipeMarketsBlock(direction: SwipeDirection) {
val searchBarText = device.uiDevice
.findObject(By.textContains(getResourceString(R.string.markets_search_header_title)))
val bounds = searchBarText.visibleBounds
val centerX = bounds.centerX()
val startY = bounds.centerY()
val endY = when (direction) {
SwipeDirection.UP -> 50
SwipeDirection.DOWN -> device.uiDevice.displayHeight - 100
}
device.uiDevice.swipe(centerX, startY, centerX, endY, 100)
}
fun BaseTestCase.openTheAppFromRecents() {
device.uiDevice.waitForIdle()

View file

@ -6,97 +6,33 @@ import com.tangem.common.extensions.swipeVertical
import com.tangem.screens.onMainScreen
import io.qameta.allure.kotlin.Allure.step
fun BaseTestCase.checkSingleCurrencyMainScreen(
cardBlockchain: String,
cardTitle: String,
withTransactions: Boolean = false,
withWalletImage: Boolean = true
) {
fun BaseTestCase.checkSingleCurrencyMainScreen(cardTitle: String) {
step("Assert card title equal '$cardTitle'") {
onMainScreen { walletNameText.assertTextEquals(cardTitle) }
}
if (withWalletImage) {
step("Assert card image is displayed") { //TODO: create assertion method for checking images
onMainScreen { walletImage.assertIsDisplayed() }
}
} else {
step("Assert card image is not displayed") {
onMainScreen { walletImage.assertIsNotDisplayed() }
}
}
step("Assert 'Receive' button is displayed") {
onMainScreen { receiveButton.assertIsDisplayed() }
}
step("Assert 'Buy' button is displayed") {
onMainScreen { buyButton.assertIsDisplayed() }
}
step("Assert 'Send' button is displayed") {
onMainScreen { sendButton.assertIsDisplayed() }
}
step("Assert 'Sell' button is displayed") {
onMainScreen { sellButton.assertIsDisplayed() }
}
step("Assert 'Swap' button is not displayed") {
onMainScreen { swapButton.assertIsNotDisplayed() }
}
step("Assert 'Market Price' on single card main screen is displayed") {
onMainScreen { marketPriceBlock().assertIsDisplayed() }
}
step("Assert 'Market Price' title equals $cardBlockchain Market Price") {
onMainScreen { marketPriceText.assertTextContains("$cardBlockchain Market Price") }
}
step("Swipe up") {
swipeVertical(SwipeDirection.UP)
}
if (withTransactions) {
step("Assert 'Transactions' block is displayed") {
onMainScreen { transactionsExplorerText.assertIsDisplayed() }
}
step("Assert 'Transactions' title is displayed") {
onMainScreen { transactionsTitle.assertIsDisplayed() }
}
step("Assert 'Explorer' icon is displayed") {
onMainScreen { transactionsExplorerIcon.assertIsDisplayed() }
}
} else {
step("Assert empty 'Transactions' block is displayed") {
onMainScreen { emptyTransactionBlock.assertIsDisplayed() }
}
step("Assert empty 'Transactions' block icon is displayed") {
onMainScreen { emptyTransactionBlockIcon.assertIsDisplayed() }
}
step("Assert empty 'Transactions' block text is displayed") {
onMainScreen { emptyTransactionBlockText.assertIsDisplayed() }
}
step("Assert empty 'Transactions' block 'Explore' button is displayed") {
onMainScreen { emptyTransactionBlockExploreButton.assertIsDisplayed() }
}
}
step("Assert 'Add & Manage' button is not displayed") {
onMainScreen { addAndManageButtonWithoutLazySearch.assertIsNotDisplayed() }
}
}
fun BaseTestCase.checkMultiCurrencyMainScreen(
devicesCount: String,
cardTitle: String,
withWalletImage: Boolean = true
) {
step("Assert card title equal '$cardTitle'") {
onMainScreen { walletNameText.assertTextEquals(cardTitle) }
}
if (withWalletImage) {
step("Assert card image is displayed") {
onMainScreen { walletImage.assertIsDisplayed() }
}
} else {
step("Assert card image is not displayed") {
onMainScreen { walletImage.assertIsNotDisplayed() }
}
}
step("Assert devices count equal to '$devicesCount'") {
onMainScreen { walletDevicesCount.assertTextContains(devicesCount) }
}
step("Assert 'Buy' button is displayed") {
onMainScreen { buyButton.assertIsDisplayed() }
}

View file

@ -1,28 +1,28 @@
package com.tangem.scenarios
import androidx.compose.ui.test.ExperimentalTestApi
import com.tangem.common.BaseTestCase
import com.tangem.common.extensions.SwipeDirection
import com.tangem.common.extensions.clickWithAssertion
import com.tangem.common.extensions.swipeVertical
import com.tangem.screens.onMainScreen
import com.tangem.screens.onMarketsExchangesScreen
import com.tangem.screens.onMarketsScreen
import com.tangem.screens.onMarketsTokenDetailsScreen
import io.qameta.allure.kotlin.Allure.step
fun BaseTestCase.openMarketTokenDetailsScreen(blockchainName: String, tokenName: String) {
fun BaseTestCase.openTokenDetailsFromMarketsScreen(blockchainName: String, tokenName: String) {
step("Open 'Markets' screen") {
onMainScreen { searchThroughMarketPlaceholder.performClick() }
waitForIdle()
}
step("Click on 'Search' placeholder") {
onMarketsScreen { searchThroughMarketPlaceholder.performClick() }
}
step("Click on $blockchainName blockchain") {
waitForIdle()
onMarketsScreen { tokenWithTitle(blockchainName).clickWithAssertion() }
}
step("Click on $tokenName token") {
step("Click on 'In your portfolio' block") {
waitForIdle()
onMarketsTokenDetailsScreen { inYourPortfolioBlock.clickWithAssertion() }
}
step("Click on $tokenName token in 'Your portfolio' bottom sheet") {
waitForIdle()
onMarketsTokenDetailsScreen { tokenWithTitle(tokenName).clickWithAssertion() }
}
@ -59,6 +59,7 @@ fun BaseTestCase.openMarketsScreen() {
}
}
@OptIn(ExperimentalTestApi::class)
fun BaseTestCase.openMarketsExchangesScreen(tokenName: String, shouldClickSeeAllButton: Boolean = false) {
openMarketsScreen()
if (shouldClickSeeAllButton)
@ -69,9 +70,8 @@ fun BaseTestCase.openMarketsExchangesScreen(tokenName: String, shouldClickSeeAll
onMarketsScreen { tokenWithTitle(tokenName).clickWithAssertion() }
waitForIdle()
}
step("Scroll down") {
swipeVertical(SwipeDirection.UP)
swipeVertical(SwipeDirection.UP)
step("Scroll to 'Listed on exchanges' block") {
onMarketsScreen { scrollToListedOnBlock() }
}
step("Click on 'Listed on exchanges' block") {
onMarketsScreen { listedOnBlockContainer.performClick() }

View file

@ -6,7 +6,6 @@ import com.tangem.common.constants.TestConstants.HOLD_DURATION_MS
import com.tangem.common.constants.TestConstants.QUOTES_API_SCENARIO
import com.tangem.common.constants.TestConstants.USER_TOKENS_API_SCENARIO
import com.tangem.common.constants.TestConstants.WAIT_UNTIL_TIMEOUT_LONG
import com.tangem.common.extensions.assertIsDimmed
import com.tangem.common.extensions.clickWithAssertion
import com.tangem.common.utils.setWireMockScenarioState
import com.tangem.screens.*
@ -34,8 +33,11 @@ fun BaseTestCase.openSendScreen(
step("Click on token with name: '$tokenName'") {
onMainScreen { tokenWithTitleAndAddress(tokenName).clickWithAssertion() }
}
step("Click on 'Send' button") {
onTokenDetailsScreen { sendButton().performClick() }
step("Click on 'Transfer' button") {
onTokenDetailsScreen { transferButton.clickWithAssertion() }
}
step("Click on 'Send' button in bottom sheet") {
onTransferBottomSheet { sendButton.clickWithAssertion() }
}
}
@ -91,11 +93,11 @@ fun BaseTestCase.openSendAddressScreen(
step("Click on token with name: '$tokenName'") {
onMainScreen { tokenWithTitleAndAddress(tokenName).clickWithAssertion() }
}
step("Assert 'Send' button is not dimmed") {
onTokenDetailsScreen { sendButton().assertIsDimmed(false) }
step("Click on 'Transfer' button") {
onTokenDetailsScreen { transferButton.clickWithAssertion() }
}
step("Click on 'Send' button") {
onTokenDetailsScreen { sendButton().performClick() }
step("Click on 'Send' button in bottom sheet") {
onTransferBottomSheet { sendButton.clickWithAssertion() }
}
step("Type '$inputAmount' in input text field") {
onSendScreen {
@ -109,6 +111,13 @@ fun BaseTestCase.openSendAddressScreen(
step("Assert 'Send Address' container is displayed") {
onSendAddressScreen { container.assertIsDisplayed() }
}
step("Wait for recipient list to load") {
composeTestRule.waitUntil(timeoutMillis = WAIT_UNTIL_TIMEOUT_LONG) {
runCatching {
onSendAddressScreen { addressesShimmer.assertIsNotDisplayed() }
}.isSuccess
}
}
}
fun BaseTestCase.checkScanQrScreen(emptyClipboard: Boolean = true) {
@ -244,8 +253,11 @@ fun BaseTestCase.selectTokenToSendViaSwap(
networkName: String,
networkType: String? = null,
) {
step("Click on 'Send' button") {
onTokenDetailsScreen { sendButton().performClick() }
step("Click on 'Transfer' button") {
onTokenDetailsScreen { transferButton.clickWithAssertion() }
}
step("Click on 'Send' button in bottom sheet") {
onTransferBottomSheet { sendButton.clickWithAssertion() }
}
step("Click on 'Swap to another token' button") {
onSendScreen { swapToAnotherTokenButton.performClick() }

View file

@ -10,6 +10,7 @@ import com.tangem.common.BaseTestCase
import com.tangem.common.constants.TestConstants.HOLD_DURATION_MS
import com.tangem.common.constants.TestConstants.WAIT_UNTIL_TIMEOUT_LONG
import com.tangem.common.extensions.assertVisibility
import com.tangem.common.extensions.clickWhenEnabled
import com.tangem.common.extensions.clickWithAssertion
import com.tangem.common.extensions.isDisplayedSafely
import com.tangem.core.ui.R as CoreUiR
@ -43,8 +44,8 @@ fun BaseTestCase.openSwapScreen(
}
SwapEntryPoint.TokenDetails -> step("Click on 'Swap' button on 'Token details' screen") {
onTokenDetailsScreen { swapButton().performClick() }
}
onTokenDetailsScreen { swapButton.clickWhenEnabled() }
}
SwapEntryPoint.MarketsTokenDetails -> step("Click on 'Swap' button on 'Markets' token details screen") {
onMarketsTokenDetailsScreen { swapPortfolioQuickActionButton.performClick() }

View file

@ -0,0 +1,41 @@
package com.tangem.screens
import androidx.compose.ui.test.SemanticsNodeInteractionsProvider
import com.tangem.common.BaseTestCase
import com.tangem.core.ui.R
import com.tangem.core.ui.test.BaseBottomSheetTestTags
import io.github.kakaocup.compose.node.element.ComposeScreen
import io.github.kakaocup.compose.node.element.ComposeScreen.Companion.onComposeScreen
import io.github.kakaocup.compose.node.element.KNode
import io.github.kakaocup.kakao.common.utilities.getResourceString
import androidx.compose.ui.test.hasText as withText
class AddFundsBottomSheetPageObject(semanticsProvider: SemanticsNodeInteractionsProvider) :
ComposeScreen<AddFundsBottomSheetPageObject>(
semanticsProvider = semanticsProvider,
viewBuilderAction = { hasTestTag(BaseBottomSheetTestTags.CONTAINER) },
) {
val buyButton: KNode = child {
hasAnyChild(withText(getResourceString(R.string.common_buy)))
useUnmergedTree = true
}
val swapButton: KNode = child {
hasAnyChild(withText(getResourceString(R.string.common_swap)))
useUnmergedTree = true
}
val receiveButton: KNode = child {
hasAnyChild(withText(getResourceString(R.string.common_receive)))
useUnmergedTree = true
}
val closeButton: KNode = child {
hasAnyChild(withText(getResourceString(R.string.common_close)))
useUnmergedTree = true
}
}
internal fun BaseTestCase.onAddFundsBottomSheet(function: AddFundsBottomSheetPageObject.() -> Unit) =
onComposeScreen(composeTestRule, function)

View file

@ -11,7 +11,10 @@ import io.github.kakaocup.compose.node.element.KNode
import io.github.kakaocup.kakao.common.utilities.getResourceString
class AddTokenBottomSheetPageObject(semanticsProvider: SemanticsNodeInteractionsProvider) :
ComposeScreen<AddTokenBottomSheetPageObject>(semanticsProvider = semanticsProvider) {
ComposeScreen<AddTokenBottomSheetPageObject>(
semanticsProvider = semanticsProvider,
viewBuilderAction = { hasTestTag(BaseBottomSheetTestTags.CONTAINER) },
) {
val title: KNode = child {
hasTestTag(BaseBottomSheetTestTags.TITLE)
@ -23,6 +26,12 @@ class AddTokenBottomSheetPageObject(semanticsProvider: SemanticsNodeInteractions
hasText(getResourceString(R.string.common_add))
useUnmergedTree = true
}
val laterButton: KNode = child {
hasTestTag(BaseButtonTestTags.TEXT)
hasText(getResourceString(R.string.common_later))
useUnmergedTree = true
}
}
internal fun BaseTestCase.onAddTokenBottomSheet(function: AddTokenBottomSheetPageObject.() -> Unit) =

View file

@ -5,6 +5,7 @@ import androidx.compose.ui.test.ExperimentalTestApi
import androidx.compose.ui.test.SemanticsMatcher
import androidx.compose.ui.test.SemanticsNodeInteractionsProvider
import androidx.compose.ui.test.hasAnyAncestor
import androidx.compose.ui.test.swipeUp
import com.tangem.common.BaseTestCase
import com.tangem.common.extensions.getQuantityString
import com.tangem.common.extensions.hasLazyListItemPosition
@ -49,27 +50,32 @@ class MainScreenPageObject(semanticsProvider: SemanticsNodeInteractionsProvider)
val buyButton: KNode = child {
hasTestTag(BaseActionButtonsBlockTestTags.ACTION_BUTTON)
hasText(getResourceString(R.string.common_buy))
hasAnyDescendant(withText(getResourceString(R.string.common_buy)))
useUnmergedTree = true
}
val sendButton: KNode = child {
hasTestTag(BaseActionButtonsBlockTestTags.ACTION_BUTTON)
hasText(getResourceString(R.string.common_send))
hasAnyDescendant(withText(getResourceString(R.string.common_send)))
useUnmergedTree = true
}
val receiveButton: KNode = child {
hasTestTag(BaseActionButtonsBlockTestTags.ACTION_BUTTON)
hasText(getResourceString(R.string.common_receive))
hasAnyDescendant(withText(getResourceString(R.string.common_receive)))
useUnmergedTree = true
}
val sellButton: KNode = child {
hasTestTag(BaseActionButtonsBlockTestTags.ACTION_BUTTON)
hasText(getResourceString(R.string.common_sell))
hasAnyDescendant(withText(getResourceString(R.string.common_sell)))
useUnmergedTree = true
}
val swapButton: KNode = child {
hasTestTag(BaseActionButtonsBlockTestTags.ACTION_BUTTON)
hasText(getResourceString(R.string.common_swap))
hasAnyDescendant(withText(getResourceString(R.string.common_swap)))
useUnmergedTree = true
}
val walletNameText: KNode = child {
@ -82,13 +88,21 @@ class MainScreenPageObject(semanticsProvider: SemanticsNodeInteractionsProvider)
useUnmergedTree = true
}
val walletDevicesCount: KNode = child {
hasTestTag(MainScreenTestTags.DEVICES_COUNT)
useUnmergedTree = true
/**
* Collapses the collapsing header via a touch-based swipe so that items near the bottom
* of the lazy list fall within screen bounds before programmatic childWith scroll.
* Required because TangemCollapsingTopBar places the body at y=collapsingHeight, which
* pushes lower list items off-screen when the header is expanded.
*/
private fun collapseHeader() {
screenContainer {
performTouchInput { swipeUp(startY = visibleSize.height * 0.6f, endY = visibleSize.height * 0.1f) }
}
}
@OptIn(ExperimentalTestApi::class)
fun marketPriceBlock(): LazyListItemNode {
collapseHeader()
return lazyList.childWith<LazyListItemNode> {
hasTestTag(MarketPriceBlockTestTags.BLOCK)
useUnmergedTree = true
@ -231,6 +245,7 @@ class MainScreenPageObject(semanticsProvider: SemanticsNodeInteractionsProvider)
*/
@OptIn(ExperimentalTestApi::class)
fun accountWithName(name: String): LazyListItemNode {
collapseHeader()
return lazyList.childWith<LazyListItemNode> {
hasTestTag(MainScreenTestTags.TOKEN_LIST_ITEM)
hasAnyDescendant(withText(name))
@ -243,6 +258,7 @@ class MainScreenPageObject(semanticsProvider: SemanticsNodeInteractionsProvider)
*/
@OptIn(ExperimentalTestApi::class)
fun tokenWithTitleAndAddress(tokenTitle: String): KNode {
collapseHeader()
return lazyList.childWith<LazyListItemNode> {
hasTestTag(MainScreenTestTags.TOKEN_LIST_ITEM)
hasText(tokenTitle)
@ -255,6 +271,7 @@ class MainScreenPageObject(semanticsProvider: SemanticsNodeInteractionsProvider)
@OptIn(ExperimentalTestApi::class)
fun tokenWithCustomDerivationIcon(tokenTitle: String): KNode {
collapseHeader()
return lazyList.childWith<LazyListItemNode> {
hasTestTag(MainScreenTestTags.TOKEN_LIST_ITEM)
hasText(tokenTitle)
@ -267,6 +284,7 @@ class MainScreenPageObject(semanticsProvider: SemanticsNodeInteractionsProvider)
@OptIn(ExperimentalTestApi::class)
fun addAndManageButton(): KNode {
collapseHeader()
return lazyList.childWith<LazyListItemNode> {
hasTestTag(MainScreenTestTags.ADD_AND_MANAGE_BUTTON)
}.child<KNode> {
@ -282,11 +300,12 @@ class MainScreenPageObject(semanticsProvider: SemanticsNodeInteractionsProvider)
}
val searchThroughMarketPlaceholder: KNode = child {
hasText(getResourceString(R.string.markets_search_header_title))
hasText(getResourceString(R.string.markets_search_title_placeholder))
useUnmergedTree = true
}
fun tokenNetworkGroupTitle(tokenNetwork: String): KNode {
collapseHeader()
return lazyList.child {
hasTestTag(MainScreenTestTags.TOKEN_LIST_ITEM)
hasAnyChild(withText(tokenNetwork))
@ -296,6 +315,7 @@ class MainScreenPageObject(semanticsProvider: SemanticsNodeInteractionsProvider)
@OptIn(ExperimentalTestApi::class)
fun tokenWithTitleAndPosition(tokenTitle: String, index: Int): KNode {
collapseHeader()
return lazyList.childWith<LazyListItemNode> {
hasTestTag(MainScreenTestTags.TOKEN_LIST_ITEM)
hasText(tokenTitle)

View file

@ -2,11 +2,9 @@ package com.tangem.screens
import androidx.compose.ui.semantics.SemanticsNode
import androidx.compose.ui.test.SemanticsNodeInteractionsProvider
import androidx.compose.ui.test.hasParent
import androidx.compose.ui.test.hasTestTag
import com.tangem.common.BaseTestCase
import com.tangem.core.ui.test.TokenElementsTestTags
import com.tangem.core.ui.test.TopAppBarTestTags
import com.tangem.features.onramp.impl.R
import io.github.kakaocup.compose.node.element.ComposeScreen
import io.github.kakaocup.compose.node.element.ComposeScreen.Companion.onComposeScreen
@ -23,16 +21,15 @@ class MarketsExchangesPageObject(private val provider: SemanticsNodeInteractions
fun allExchangeTypeNodes(): List<SemanticsNode> =
provider
.onAllNodes(hasParent(hasParent(hasTestTag(TokenElementsTestTags.TOKEN_PRICE))))
.onAllNodes(hasTestTag(TokenElementsTestTags.TOKEN_PRICE))
.fetchSemanticsNodes()
fun allTrustScoreNodes(): List<SemanticsNode> =
provider
.onAllNodes(hasParent(hasTestTag(TokenElementsTestTags.TOKEN_CRYPTO_AMOUNT)))
.onAllNodes(hasTestTag(TokenElementsTestTags.TOKEN_CRYPTO_AMOUNT))
.fetchSemanticsNodes()
val exchangesTitle: KNode = child {
hasTestTag(TopAppBarTestTags.TITLE)
hasText(getResourceString(R.string.markets_token_details_exchanges_title))
useUnmergedTree = true
}

View file

@ -1,6 +1,8 @@
package com.tangem.screens
import androidx.compose.ui.test.ExperimentalTestApi
import androidx.compose.ui.test.SemanticsNodeInteractionsProvider
import androidx.compose.ui.test.hasTestTag
import com.tangem.common.BaseTestCase
import com.tangem.common.constants.TestConstants.MARKETS_MAIN_NETWORK_SUFFIX
import com.tangem.core.ui.test.BaseButtonTestTags
@ -15,9 +17,9 @@ import io.github.kakaocup.kakao.common.utilities.getResourceString
class MarketsPageObject(semanticsProvider: SemanticsNodeInteractionsProvider) :
ComposeScreen<MarketsPageObject>(semanticsProvider = semanticsProvider) {
val addToPortfolioButton: KNode = child {
val addButton: KNode = child {
hasTestTag(BaseButtonTestTags.TEXT)
hasText(getResourceString(R.string.common_add_to_portfolio))
hasText(getResourceString(R.string.common_add))
useUnmergedTree = true
}
@ -31,7 +33,12 @@ class MarketsPageObject(semanticsProvider: SemanticsNodeInteractionsProvider) :
}
val searchThroughMarketPlaceholder: KNode = child {
hasText(getResourceString(R.string.markets_search_header_title))
hasText(getResourceString(R.string.markets_search_title_placeholder))
useUnmergedTree = true
}
val tokenDetailsContent: KNode = child {
hasTestTag(MarketsTestTags.TOKEN_DETAILS_CONTENT)
useUnmergedTree = true
}
@ -41,7 +48,8 @@ class MarketsPageObject(semanticsProvider: SemanticsNodeInteractionsProvider) :
}
val listedOnBlockContainer: KNode = child {
hasText(getResourceString(R.string.markets_token_details_listed_on), substring = true)
hasTestTag(MarketsTestTags.LISTED_ON_BLOCK)
useUnmergedTree = true
}
val listedOnEmptyText: KNode = child {
@ -60,6 +68,13 @@ class MarketsPageObject(semanticsProvider: SemanticsNodeInteractionsProvider) :
hasText(title)
}
}
@ExperimentalTestApi
fun scrollToListedOnBlock() {
tokenDetailsContent {
performScrollToNode(hasTestTag(MarketsTestTags.LISTED_ON_BLOCK))
}
}
}
internal fun BaseTestCase.onMarketsScreen(function: MarketsPageObject.() -> Unit) =

View file

@ -3,14 +3,13 @@ package com.tangem.screens
import androidx.compose.ui.test.SemanticsNodeInteractionsProvider
import com.tangem.common.BaseTestCase
import com.tangem.core.ui.test.MarketTokenDetailsBottomSheetTestTags
import com.tangem.core.ui.test.TokenElementsTestTags
import com.tangem.features.onramp.impl.R
import io.github.kakaocup.compose.node.element.ComposeScreen
import io.github.kakaocup.compose.node.element.ComposeScreen.Companion.onComposeScreen
import io.github.kakaocup.compose.node.element.KNode
import io.github.kakaocup.kakao.common.utilities.getResourceString
import androidx.compose.ui.test.hasTestTag as withTestTag
import androidx.compose.ui.test.hasText as withText
import com.tangem.core.ui.R as CoreUiR
class MarketsTokenDetailsPageObject(semanticsProvider: SemanticsNodeInteractionsProvider) :
ComposeScreen<MarketsTokenDetailsPageObject>(semanticsProvider = semanticsProvider) {
@ -20,11 +19,14 @@ class MarketsTokenDetailsPageObject(semanticsProvider: SemanticsNodeInteractions
hasText(getResourceString(R.string.common_swap), substring = true)
}
val inYourPortfolioBlock: KNode = child {
hasText(getResourceString(CoreUiR.string.markets_portfolio_block_subtitle), substring = true)
useUnmergedTree = true
}
fun tokenWithTitle(title: String): KNode = child {
hasAnyAncestor(withTestTag(MarketTokenDetailsBottomSheetTestTags.PORTFOLIO_TOKEN_ITEM))
hasTestTag(TokenElementsTestTags.TOKEN_TITLE)
hasAnySibling(withTestTag(TokenElementsTestTags.TOKEN_ICON))
hasAnyChild(withText(title))
hasClickAction()
useUnmergedTree = true
}
}

View file

@ -28,23 +28,18 @@ class OrganizeTokensPageObject(semanticsProvider: SemanticsNodeInteractionsProvi
useUnmergedTree = true
}
private val topBarGroupButton: KNode = child {
hasTestTag(OrganizeTokensScreenTestTags.GROUP_BUTTON)
val organizeMenuButton: KNode = child {
hasTestTag(OrganizeTokensScreenTestTags.MENU_BUTTON)
useUnmergedTree = true
}
val groupButton: KNode = topBarGroupButton.child {
val groupButton: KNode = child {
hasText(getResourceString(R.string.organize_tokens_group))
useUnmergedTree = true
}
val ungroupButton: KNode = topBarGroupButton.child {
hasText(getResourceString(R.string.organize_tokens_ungroup))
useUnmergedTree = true
}
val sortByBalanceButton: KNode = child {
hasTestTag(OrganizeTokensScreenTestTags.SORT_BY_BALANCE_BUTTON)
hasText(getResourceString(R.string.organize_tokens_sort_by_balance))
useUnmergedTree = true
}
// endregion TopBar
@ -84,7 +79,7 @@ class OrganizeTokensPageObject(semanticsProvider: SemanticsNodeInteractionsProvi
fun tokenNetworkGroupTitle(tokenNetwork: String): KNode {
return lazyList.child {
hasTestTag(OrganizeTokensScreenTestTags.GROUP_TITLE_ITEM)
hasAnyChild(withText(tokenNetwork))
hasAnyDescendant(withText(tokenNetwork))
useUnmergedTree = true
}
}

View file

@ -97,7 +97,7 @@ class SendAddressPageObject(semanticsProvider: SemanticsNodeInteractionsProvider
): KNode = child {
hasTestTag(SendAddressScreenTestTags.RECENT_ADDRESS_ITEM)
hasAnyChild(withTestTag(SendAddressScreenTestTags.RECENT_ADDRESS_ICON))
hasAnyDescendant(withText(recipientAddress))
hasAnyDescendant(withText(recipientAddress, substring = true))
hasAnyDescendant(withTestTag(SendAddressScreenTestTags.RECENT_ADDRESS_TEXT))
useUnmergedTree = true
if (description != null) {

View file

@ -1,20 +1,15 @@
package com.tangem.screens
import androidx.compose.ui.test.ExperimentalTestApi
import androidx.compose.ui.test.SemanticsMatcher
import androidx.compose.ui.test.SemanticsNodeInteractionsProvider
import com.tangem.common.BaseTestCase
import com.tangem.common.utils.LazyListItemNode
import com.tangem.core.ui.test.BaseActionButtonsBlockTestTags
import com.tangem.core.ui.test.BaseButtonTestTags
import com.tangem.core.ui.test.NotificationTestTags
import com.tangem.core.ui.test.TokenDetailsScreenTestTags
import com.tangem.core.ui.utils.LazyListItemPositionSemantics
import com.tangem.features.tokendetails.impl.R
import io.github.kakaocup.compose.node.element.ComposeScreen
import io.github.kakaocup.compose.node.element.ComposeScreen.Companion.onComposeScreen
import io.github.kakaocup.compose.node.element.KNode
import io.github.kakaocup.compose.node.element.lazylist.KLazyListNode
import io.github.kakaocup.kakao.common.utilities.getResourceString
import androidx.compose.ui.test.hasTestTag as withTestTag
import androidx.compose.ui.test.hasText as withText
@ -36,18 +31,8 @@ class TokenDetailsPageObject(semanticsProvider: SemanticsNodeInteractionsProvide
useUnmergedTree = true
}
val availableStakingBlockTitle: KNode = child {
hasTestTag(TokenDetailsScreenTestTags.STAKING_SERVICE_TITLE)
useUnmergedTree = true
}
val availableStakingBlockText: KNode = child {
hasTestTag(TokenDetailsScreenTestTags.STAKING_SERVICE_TEXT)
useUnmergedTree = true
}
val availableStakingBlockCurrencyIcon: KNode = child {
hasTestTag(TokenDetailsScreenTestTags.STAKING_CURRENCY_ICON)
fun availableStakingBlockText(apy: String): KNode = child {
hasText(getResourceString(R.string.token_details_earn_staking_subtitle, apy))
useUnmergedTree = true
}
@ -62,23 +47,17 @@ class TokenDetailsPageObject(semanticsProvider: SemanticsNodeInteractionsProvide
useUnmergedTree = true
}
val stakingDot: KNode = child {
hasTestTag(TokenDetailsScreenTestTags.STAKING_DOT)
useUnmergedTree = true
}
val stakingTokenAmount: KNode = child {
hasTestTag(TokenDetailsScreenTestTags.STAKING_TOKEN_AMOUNT)
useUnmergedTree = true
}
val stakingChevronIcon: KNode = child {
hasTestTag(TokenDetailsScreenTestTags.STAKING_CHEVRON_ICON)
useUnmergedTree = true
val stakingTitle: KNode = child {
hasText(getResourceString(R.string.common_staking))
}
val stakingTitle: KNode = child {
hasText(getResourceString(R.string.staking_native))
val stakingEnabledTitle: KNode = child {
hasText(getResourceString(R.string.staking_enabled))
}
val title: KNode = child {
@ -90,46 +69,22 @@ class TokenDetailsPageObject(semanticsProvider: SemanticsNodeInteractionsProvide
useUnmergedTree = true
}
private val horizontalActionChips = KLazyListNode(
semanticsProvider = semanticsProvider,
viewBuilderAction = { hasTestTag(BaseActionButtonsBlockTestTags.HORIZONTAL_ACTION_CHIPS) },
itemTypeBuilder = { itemType(::LazyListItemNode) },
positionMatcher = { position ->
SemanticsMatcher.expectValue(
LazyListItemPositionSemantics,
position
)
}
)
@OptIn(ExperimentalTestApi::class)
fun receiveButton(): LazyListItemNode = horizontalActionChips.childWith<LazyListItemNode> {
val addFundsButton: KNode = child {
hasTestTag(BaseActionButtonsBlockTestTags.ACTION_BUTTON)
hasText(getResourceString(R.string.common_receive))
hasAnyDescendant(withText(getResourceString(R.string.tangempay_card_details_add_funds)))
useUnmergedTree = true
}
@OptIn(ExperimentalTestApi::class)
fun swapButton(): LazyListItemNode = horizontalActionChips.childWith<LazyListItemNode> {
val swapButton: KNode = child {
hasTestTag(BaseActionButtonsBlockTestTags.ACTION_BUTTON)
hasText(getResourceString(R.string.common_swap))
hasAnyDescendant(withText(getResourceString(R.string.common_swap)))
useUnmergedTree = true
}
@OptIn(ExperimentalTestApi::class)
fun sellButton(): LazyListItemNode = horizontalActionChips.childWith<LazyListItemNode> {
val transferButton: KNode = child {
hasTestTag(BaseActionButtonsBlockTestTags.ACTION_BUTTON)
hasText(getResourceString(R.string.common_sell))
}
@OptIn(ExperimentalTestApi::class)
fun buyButton(): LazyListItemNode = horizontalActionChips.childWith<LazyListItemNode> {
hasTestTag(BaseActionButtonsBlockTestTags.ACTION_BUTTON)
hasText(getResourceString(R.string.common_buy))
}
@OptIn(ExperimentalTestApi::class)
fun sendButton(): LazyListItemNode = horizontalActionChips.childWith<LazyListItemNode> {
hasTestTag(BaseActionButtonsBlockTestTags.ACTION_BUTTON)
hasText(getResourceString(R.string.common_send))
hasAnyDescendant(withText(getResourceString(R.string.common_transfer)))
useUnmergedTree = true
}
fun networkFeeNotificationIcon(feeCurrencyName: String): KNode = child {
@ -209,7 +164,6 @@ class TokenDetailsPageObject(semanticsProvider: SemanticsNodeInteractionsProvide
hasAnyDescendant(withTestTag(TokenDetailsScreenTestTags.EXPRESS_STATUS_ITEM_SWAP_ICON))
hasAnyDescendant(withTestTag(TokenDetailsScreenTestTags.EXPRESS_STATUS_ITEM_TO_ICON))
hasAnyDescendant(withTestTag(TokenDetailsScreenTestTags.EXPRESS_STATUS_ITEM_TO_AMOUNT))
hasAnyDescendant(withTestTag(TokenDetailsScreenTestTags.EXPRESS_STATUS_ITEM_CHEVRON_ICON))
useUnmergedTree = true
}
}

View file

@ -0,0 +1,41 @@
package com.tangem.screens
import androidx.compose.ui.test.SemanticsNodeInteractionsProvider
import com.tangem.common.BaseTestCase
import com.tangem.core.ui.R
import com.tangem.core.ui.test.BaseBottomSheetTestTags
import io.github.kakaocup.compose.node.element.ComposeScreen
import io.github.kakaocup.compose.node.element.ComposeScreen.Companion.onComposeScreen
import io.github.kakaocup.compose.node.element.KNode
import io.github.kakaocup.kakao.common.utilities.getResourceString
import androidx.compose.ui.test.hasText as withText
class TransferBottomSheetPageObject(semanticsProvider: SemanticsNodeInteractionsProvider) :
ComposeScreen<TransferBottomSheetPageObject>(
semanticsProvider = semanticsProvider,
viewBuilderAction = { hasTestTag(BaseBottomSheetTestTags.CONTAINER) },
) {
val sendButton: KNode = child {
hasAnyChild(withText(getResourceString(R.string.common_send)))
useUnmergedTree = true
}
val swapButton: KNode = child {
hasAnyChild(withText(getResourceString(R.string.common_swap)))
useUnmergedTree = true
}
val sellButton: KNode = child {
hasAnyChild(withText(getResourceString(R.string.common_sell)))
useUnmergedTree = true
}
val closeButton: KNode = child {
hasAnyChild(withText(getResourceString(R.string.common_close)))
useUnmergedTree = true
}
}
internal fun BaseTestCase.onTransferBottomSheet(function: TransferBottomSheetPageObject.() -> Unit) =
onComposeScreen(composeTestRule, function)

View file

@ -27,6 +27,7 @@ import com.tangem.screens.onSendScreen
import com.tangem.screens.onStoriesScreen
import com.tangem.screens.onTokenDetailsScreen
import com.tangem.screens.onMainScreenTopBar
import com.tangem.screens.onTransferBottomSheet
import com.tangem.tap.domain.sdk.mocks.MockProvider
import dagger.hilt.android.testing.HiltAndroidTest
import io.qameta.allure.kotlin.AllureId
@ -94,8 +95,11 @@ class FeedbackTest : BaseTestCase() {
step("Click on token with name: '$tokenName'") {
onMainScreen { tokenWithTitleAndAddress(tokenName).clickWithAssertion() }
}
step("Click 'Send' button") {
onTokenDetailsScreen { sendButton().performClick() }
step("Click on 'Transfer' button") {
onTokenDetailsScreen { transferButton.clickWithAssertion() }
}
step("Click on 'Send' button in bottom sheet") {
onTransferBottomSheet { sendButton.clickWithAssertion() }
}
step("Type '$sendAmount' in input text field") {
onSendScreen {

View file

@ -22,7 +22,8 @@ class OrganizeTokensTest : BaseTestCase() {
fun groupTokensTest() {
setupHooks().run {
val tokenTitle = "Ethereum"
val tokenNetwork = "Ethereum network"
val networkTitleOrganize = "Ethereum"
val networkTitleMain = "Ethereum network"
step("Open 'Main Screen'") {
openMainScreen()
@ -39,17 +40,20 @@ class OrganizeTokensTest : BaseTestCase() {
tokenWithTitle(tokenTitle).assertIsDisplayed()
}
}
step("Open organize menu") {
onOrganizeTokensScreen { organizeMenuButton.clickWithAssertion() }
}
step("Click 'Group' button") {
onOrganizeTokensScreen { groupButton.clickWithAssertion() }
}
step("Assert tokens were grouped on 'Organize tokens' screen") {
onOrganizeTokensScreen { tokenNetworkGroupTitle(tokenNetwork).assertIsDisplayed() }
onOrganizeTokensScreen { tokenNetworkGroupTitle(networkTitleOrganize).assertIsDisplayed() }
}
step("Click 'Apply' button") {
onOrganizeTokensScreen { applyButton.clickWithAssertion() }
}
step("Assert tokens were grouped on 'Main screen'") {
onMainScreen { tokenNetworkGroupTitle(tokenNetwork).assertIsDisplayed() }
onMainScreen { tokenNetworkGroupTitle(networkTitleMain).assertIsDisplayed() }
}
step("Open 'Organize tokens' screen") {
openOrganizeTokensScreen()
@ -60,17 +64,20 @@ class OrganizeTokensTest : BaseTestCase() {
tokenWithTitle(tokenTitle).assertIsDisplayed()
}
}
step("Click 'Ungroup' button") {
onOrganizeTokensScreen { ungroupButton.clickWithAssertion() }
step("Open organize menu") {
onOrganizeTokensScreen { organizeMenuButton.clickWithAssertion() }
}
step("Click 'Group' checkbox again to ungroup") {
onOrganizeTokensScreen { groupButton.clickWithAssertion() }
}
step("Assert tokens were ungrouped on 'Organize tokens' screen") {
onOrganizeTokensScreen { tokenNetworkGroupTitle(tokenNetwork).assertIsNotDisplayed() }
onOrganizeTokensScreen { tokenNetworkGroupTitle(networkTitleOrganize).assertIsNotDisplayed() }
}
step("Click 'Apply' button") {
onOrganizeTokensScreen { applyButton.clickWithAssertion() }
}
step("Assert tokens were ungrouped on 'Main screen'") {
onMainScreen { tokenNetworkGroupTitle(tokenNetwork).assertIsNotDisplayed() }
onMainScreen { tokenNetworkGroupTitle(networkTitleMain).assertIsNotDisplayed() }
}
}
}
@ -185,6 +192,9 @@ class OrganizeTokensTest : BaseTestCase() {
tokenWithTitleAndPosition(polExMaticTitle, 3).assertIsDisplayed()
}
}
step("Open organize menu") {
onOrganizeTokensScreen { organizeMenuButton.clickWithAssertion() }
}
step("Click 'By Balance' button") {
onOrganizeTokensScreen {
sortByBalanceButton.clickWithAssertion()

View file

@ -35,11 +35,7 @@ class ScanCardTest : BaseTestCase() {
openMainScreen(cardType)
}
step("Check 'Main' screen for '${cardType.name}' $cardBlockchain card") {
checkSingleCurrencyMainScreen(
cardBlockchain = cardBlockchain,
cardTitle = cardType.name,
withTransactions = true
)
checkSingleCurrencyMainScreen(cardTitle = cardType.name)
}
}
}
@ -57,7 +53,7 @@ class ScanCardTest : BaseTestCase() {
openMainScreen(mockContent = cardType, isTwinsCard = true)
}
step("Check 'Main' screen for '$cardName' $cardBlockchain card") {
checkSingleCurrencyMainScreen(cardBlockchain = cardBlockchain, cardTitle = cardName)
checkSingleCurrencyMainScreen(cardTitle = cardName)
}
}
}
@ -66,7 +62,6 @@ class ScanCardTest : BaseTestCase() {
@DisplayName("Scan: Card with Secp256k1 curve")
@Test
fun secpk1CurveCardScanTest() {
val devicesCount = "1 device"
val cardType: MockContent = Secpk1CurveMockContent
val cardName = "Wallet"
val card = "card with Secp256k1 curve"
@ -75,12 +70,8 @@ class ScanCardTest : BaseTestCase() {
step("Open 'Main Screen' on $card") {
openMainScreen(mockContent = cardType)
}
step("Check 'Main' screen for $card curve with devices count = '$devicesCount'") {
checkMultiCurrencyMainScreen(
devicesCount = devicesCount,
cardTitle = cardName,
withWalletImage = false
)
step("Check 'Main' screen for $card curve") {
checkMultiCurrencyMainScreen(cardTitle = cardName)
}
}
}
@ -99,11 +90,7 @@ class ScanCardTest : BaseTestCase() {
openMainScreen(mockContent = cardType)
}
step("Check 'Main' screen for $card with blockchain: '$cardBlockchain'") {
checkSingleCurrencyMainScreen(
cardBlockchain = cardBlockchain,
cardTitle = cardName,
withWalletImage = false
)
checkSingleCurrencyMainScreen(cardTitle = cardName)
}
}
}
@ -112,7 +99,6 @@ class ScanCardTest : BaseTestCase() {
@DisplayName("Scan: 'Shiba' card")
@Test
fun shibaCardScanTest() {
val devicesCount = "2 devices"
val cardType: MockContent = ShibaMockContent
val cardName = "Wallet"
val card = "Shiba"
@ -121,8 +107,8 @@ class ScanCardTest : BaseTestCase() {
step("Open 'Main Screen' on '$card' card") {
openMainScreen(mockContent = cardType)
}
step("Check 'Main' screen for '$card' card with devices count = '$devicesCount'") {
checkMultiCurrencyMainScreen(devicesCount, cardName)
step("Check 'Main' screen for '$card' card") {
checkMultiCurrencyMainScreen(cardName)
}
}
}
@ -131,7 +117,6 @@ class ScanCardTest : BaseTestCase() {
@DisplayName("Scan: 'Ring'")
@Test
fun ringScanTest() {
val devicesCount = "3 devices"
val cardType: ProductType = ProductType.Ring
val cardName = "Wallet"
val ring = "Ring"
@ -140,8 +125,8 @@ class ScanCardTest : BaseTestCase() {
step("Open 'Main Screen' on '$ring'") {
openMainScreen(productType = cardType)
}
step("Check 'Main' screen for '$ring' with devices count = '$devicesCount'") {
checkMultiCurrencyMainScreen(devicesCount, cardName)
step("Check 'Main' screen for '$ring'") {
checkMultiCurrencyMainScreen(cardName)
}
}
}
@ -150,7 +135,6 @@ class ScanCardTest : BaseTestCase() {
@DisplayName("Scan: 'Wallet' card")
@Test
fun walletCardScanTest() {
val devicesCount = "1 device"
val cardType: ProductType = ProductType.Wallet
val cardName = "Wallet"
@ -158,8 +142,8 @@ class ScanCardTest : BaseTestCase() {
step("Open 'Main Screen' on '$cardName' card") {
openMainScreen(productType = cardType)
}
step("Check 'Main' screen for '$cardName' card with devices count = '$devicesCount'") {
checkMultiCurrencyMainScreen(devicesCount, cardName)
step("Check 'Main' screen for '$cardName' card") {
checkMultiCurrencyMainScreen(cardName)
}
}
}
@ -168,7 +152,6 @@ class ScanCardTest : BaseTestCase() {
@DisplayName("Scan: 'Wallet 2' card")
@Test
fun wallet2ScanTest() {
val devicesCount = "2 devices"
val cardType: MockContent = Wallet2MockContent
val cardName = "Wallet"
val card = "Wallet 2"
@ -177,8 +160,8 @@ class ScanCardTest : BaseTestCase() {
step("Open 'Main Screen' on '$card' card") {
openMainScreen(mockContent = cardType)
}
step("Check 'Main' screen for '$card' card with devices count = '$devicesCount'") {
checkMultiCurrencyMainScreen(devicesCount, cardName)
step("Check 'Main' screen for '$card' card") {
checkMultiCurrencyMainScreen(cardName)
}
}
}
@ -187,7 +170,6 @@ class ScanCardTest : BaseTestCase() {
@DisplayName("Scan: Card with 4.12 firmware")
@Test
fun firmware412CardScanTest() {
val devicesCount = "1 device"
val cardType: MockContent = Firmware412MockContent
val cardName = "Tangem card"
val card = "card with 4.12 firmware"
@ -196,8 +178,8 @@ class ScanCardTest : BaseTestCase() {
step("Open 'Main Screen' on '$card'") {
openMainScreen(mockContent = cardType)
}
step("Check 'Main' screen for '$card' with devices count = '$devicesCount'") {
checkMultiCurrencyMainScreen(devicesCount, cardName)
step("Check 'Main' screen for '$card'") {
checkMultiCurrencyMainScreen(cardName)
}
}
}

View file

@ -56,20 +56,14 @@ class StakingTest : BaseTestCase() {
onTokenDetailsScreen { stakingBlock.assertIsDisplayed() }
}
step("Assert 'Staking title' is displayed") {
onTokenDetailsScreen { stakingTitle.assertIsDisplayed() }
onTokenDetailsScreen { stakingEnabledTitle.assertIsDisplayed() }
}
step("Assert 'Staking fiat amount' is displayed") {
onTokenDetailsScreen { stakingFiatAmount.assertIsDisplayed() }
}
step("Assert 'Staking dot' is displayed") {
onTokenDetailsScreen { stakingDot.assertIsDisplayed() }
}
step("Assert 'Staking token amount' is displayed") {
onTokenDetailsScreen { stakingTokenAmount.assertIsDisplayed() }
}
step("Assert 'Staking block chevron icon' is displayed") {
onTokenDetailsScreen { stakingChevronIcon.assertIsDisplayed() }
}
}
}
@ -139,6 +133,7 @@ class StakingTest : BaseTestCase() {
val scenarioName = "staking_eth_pol_balances_android"
val scenarioState = "Started"
val stakingAmount = "1"
val stakingApy = "2.84%"
setupHooks(
additionalAfterSection = {
@ -172,13 +167,10 @@ class StakingTest : BaseTestCase() {
onTokenDetailsScreen { availableStakingBlock.assertIsDisplayed() }
}
step("Assert 'Available staking block' title is displayed") {
onTokenDetailsScreen { availableStakingBlockTitle.assertIsDisplayed() }
onTokenDetailsScreen { stakingTitle.assertIsDisplayed() }
}
step("Assert 'Available staking block' text is displayed") {
onTokenDetailsScreen { availableStakingBlockText.assertIsDisplayed() }
}
step("Assert 'Available staking block' currency icon is displayed") {
onTokenDetailsScreen { availableStakingBlockCurrencyIcon.assertIsDisplayed() }
onTokenDetailsScreen { availableStakingBlockText(stakingApy).assertIsDisplayed() }
}
step("Click on 'Stake' button") {
onTokenDetailsScreen { stakeButton.clickWithAssertion() }

View file

@ -1,14 +1,10 @@
package com.tangem.tests
import com.tangem.common.BaseTestCase
import com.tangem.common.utils.resetWireMockScenarioState
import com.tangem.common.utils.setWireMockScenarioState
import com.tangem.scenarios.openMainScreen
import com.tangem.screens.onMainScreen
import com.tangem.tap.domain.sdk.mocks.content.DevWalletMockContent
import com.tangem.tap.domain.sdk.mocks.content.Wallet2WithSeedPhraseMockContent
import dagger.hilt.android.testing.HiltAndroidTest
import io.qameta.allure.kotlin.Allure.step
import io.qameta.allure.kotlin.AllureId
import io.qameta.allure.kotlin.junit4.DisplayName
import org.junit.Test

View file

@ -389,6 +389,9 @@ class MainScreenActionButtonsTest : BaseTestCase() {
step("Click on 'Buy' button") {
onMainScreen { buyButton.performClick() }
}
step("Click on token: '$tokenTitle'") {
onBuyTokenScreen { tokenWithTitleAndFiatAmount(tokenTitle).performClick() }
}
step("Click on 'Confirm' button in 'Dialog'") {
waitForIdle()
onDialog { confirmButton.clickWithAssertion() }

View file

@ -2,16 +2,17 @@ package com.tangem.tests.actionButtons
import com.tangem.common.BaseTestCase
import com.tangem.common.constants.TestConstants.WAIT_UNTIL_TIMEOUT
import com.tangem.common.extensions.assertIsDimmed
import com.tangem.common.extensions.clickWithAssertion
import com.tangem.scenarios.checkQrCodeBottomSheetScenario
import com.tangem.scenarios.goToQrCodeBottomSheet
import com.tangem.scenarios.openMainScreen
import com.tangem.scenarios.synchronizeAddresses
import com.tangem.screens.onAddFundsBottomSheet
import com.tangem.screens.onMainScreen
import com.tangem.screens.onSwapStoriesScreen
import com.tangem.screens.onSwapTokenScreen
import com.tangem.screens.onTokenDetailsScreen
import com.tangem.screens.onTransferBottomSheet
import dagger.hilt.android.testing.HiltAndroidTest
import io.qameta.allure.kotlin.AllureId
import io.qameta.allure.kotlin.junit4.DisplayName
@ -37,20 +38,41 @@ class TokenDetailsScreenActionButtonsTest : BaseTestCase() {
waitForIdle()
onMainScreen { tokenWithTitleAndAddress(tokenTitle).performClick() }
}
step("Assert 'Receive' button is displayed") {
onTokenDetailsScreen { receiveButton().assertIsDisplayed() }
}
step("Assert 'Buy' button is displayed") {
onTokenDetailsScreen { buyButton().assertIsDisplayed() }
}
step("Assert 'Send' button is displayed") {
onTokenDetailsScreen { sendButton().assertIsDisplayed() }
step("Assert 'Add funds' button is displayed") {
onTokenDetailsScreen { addFundsButton.assertIsDisplayed() }
}
step("Assert 'Swap' button is displayed") {
onTokenDetailsScreen { swapButton().assertIsDisplayed() }
onTokenDetailsScreen { swapButton.assertIsDisplayed() }
}
step("Assert 'Sell' button is displayed") {
onTokenDetailsScreen { sellButton().assertIsDisplayed() }
step("Assert 'Transfer' button is displayed") {
onTokenDetailsScreen { transferButton.assertIsDisplayed() }
}
step("Click on 'Add funds' button") {
onTokenDetailsScreen { addFundsButton.clickWithAssertion() }
}
step("Assert 'Buy' button in bottom sheet is displayed") {
onAddFundsBottomSheet { buyButton.assertIsDisplayed() }
}
step("Assert 'Swap' button in bottom sheet is displayed") {
onAddFundsBottomSheet { swapButton.assertIsDisplayed() }
}
step("Assert 'Receive' button in bottom sheet is displayed") {
onAddFundsBottomSheet { receiveButton.assertIsDisplayed() }
}
step("Click on 'Close' button in bottom sheet") {
onAddFundsBottomSheet { closeButton.clickWithAssertion() }
}
step("Click on 'Transfer' button") {
onTokenDetailsScreen { transferButton.clickWithAssertion() }
}
step("Assert 'Send' button in bottom sheet is displayed") {
onTransferBottomSheet { sendButton.assertIsDisplayed() }
}
step("Assert 'Swap' button in bottom sheet is displayed") {
onTransferBottomSheet { swapButton.assertIsDisplayed() }
}
step("Assert 'Sell' button in bottom sheet is displayed") {
onTransferBottomSheet { sellButton.assertIsDisplayed() }
}
}
}
@ -72,20 +94,41 @@ class TokenDetailsScreenActionButtonsTest : BaseTestCase() {
waitForIdle()
onMainScreen { tokenWithTitleAndAddress(tokenTitle).performClick() }
}
step("Assert 'Receive' button is not dimmed") {
onTokenDetailsScreen { receiveButton().assertIsDimmed(false) }
step("Assert 'Add funds' button is enabled") {
onTokenDetailsScreen { addFundsButton.assertIsEnabled() }
}
step("Assert 'Buy' button is not dimmed") {
onTokenDetailsScreen { buyButton().assertIsDimmed(false) }
step("Assert 'Swap' button is disabled") {
onTokenDetailsScreen { swapButton.assertIsNotEnabled() }
}
step("Assert 'Send' button is not dimmed") {
onTokenDetailsScreen { sendButton().assertIsDimmed(false) }
step("Assert 'Transfer' button is enabled") {
onTokenDetailsScreen { transferButton.assertIsEnabled() }
}
step("Assert 'Swap' button is dimmed") {
onTokenDetailsScreen { swapButton().assertIsDimmed() }
step("Click on 'Add funds' button") {
onTokenDetailsScreen { addFundsButton.clickWithAssertion() }
}
step("Assert 'Sell' button is dimmed") {
onTokenDetailsScreen { sellButton().assertIsDimmed() }
step("Assert 'Buy' button in bottom sheet is enabled") {
onAddFundsBottomSheet { buyButton.assertIsEnabled() }
}
step("Assert 'Swap' button in bottom sheet is disabled") {
onAddFundsBottomSheet { swapButton.assertIsNotEnabled() }
}
step("Assert 'Receive' button in bottom sheet is enabled") {
onAddFundsBottomSheet { receiveButton.assertIsEnabled() }
}
step("Click on 'Close' button in bottom sheet") {
onAddFundsBottomSheet { closeButton.clickWithAssertion() }
}
step("Click on 'Transfer' button") {
onTokenDetailsScreen { transferButton.clickWithAssertion() }
}
step("Assert 'Send' button in bottom sheet is enabled") {
onTransferBottomSheet { sendButton.assertIsEnabled() }
}
step("Assert 'Swap' button in bottom sheet is disabled") {
onTransferBottomSheet { swapButton.assertIsNotEnabled() }
}
step("Assert 'Sell' button in bottom sheet is disabled") {
onTransferBottomSheet { sellButton.assertIsNotEnabled() }
}
}
}
@ -109,7 +152,7 @@ class TokenDetailsScreenActionButtonsTest : BaseTestCase() {
onMainScreen { tokenWithTitleAndAddress(tokenTitle).performClick() }
}
step("Click on 'Swap' button") {
onTokenDetailsScreen { swapButton().performClick() }
onTokenDetailsScreen { swapButton.performClick() }
}
step("Close 'Stories' screen") {
onSwapStoriesScreen { closeButton.clickWithAssertion() }
@ -140,8 +183,11 @@ class TokenDetailsScreenActionButtonsTest : BaseTestCase() {
waitForIdle()
onMainScreen { tokenWithTitleAndAddress(tokenTitle).performClick() }
}
step("Click on 'Receive' button") {
onTokenDetailsScreen { receiveButton().performClick() }
step("Click on 'Add funds' button") {
onTokenDetailsScreen { addFundsButton.clickWithAssertion() }
}
step("Click on 'Receive' button in bottom sheet") {
onAddFundsBottomSheet { receiveButton.clickWithAssertion() }
}
step("Go to QR code bottom sheet") {
flakySafely(WAIT_UNTIL_TIMEOUT) {

View file

@ -1,42 +0,0 @@
package com.tangem.tests.balance
import androidx.compose.ui.test.longClick
import com.tangem.common.BaseTestCase
import com.tangem.scenarios.openMainScreen
import com.tangem.scenarios.synchronizeAddresses
import com.tangem.screens.onMainScreen
import dagger.hilt.android.testing.HiltAndroidTest
import io.qameta.allure.kotlin.AllureId
import io.qameta.allure.kotlin.junit4.DisplayName
import org.junit.Test
@HiltAndroidTest
class TotalBalanceLongTapTest : BaseTestCase() {
@Test
@AllureId("3965")
@DisplayName("Total balance: check long tap on block without biometry")
fun whenBiometryIsOffTest() {
setupHooks().run {
step("Open 'Main Screen'") {
openMainScreen()
}
step("Synchronize addresses") {
synchronizeAddresses()
}
step("Long tap on total balance block") {
onMainScreen {
totalBalanceContainer.performTouchInput {
longClick()
}
}
}
step("Assert 'Rename' button is displayed") {
onMainScreen { totalBalanceMenuRenameWallet.assertIsDisplayed() }
}
step("Assert 'Delete' button is not displayed") {
onMainScreen { totalBalanceMenuDeleteWallet.assertIsNotDisplayed() }
}
}
}
}

View file

@ -3,6 +3,7 @@ package com.tangem.tests.balance
import androidx.compose.ui.test.longClick
import com.tangem.common.BaseTestCase
import com.tangem.common.constants.TestConstants.TOTAL_BALANCE
import com.tangem.common.constants.TestConstants.WAIT_UNTIL_TIMEOUT
import com.tangem.common.extensions.*
import com.tangem.common.utils.resetWireMockScenarioState
import com.tangem.common.utils.setWireMockScenarioState
@ -82,28 +83,27 @@ class TotalBalanceUpdateTest : BaseTestCase() {
step("Set WireMock scenario: '$scenarioName' to state: '$scenarioState'") {
setWireMockScenarioState(scenarioName = scenarioName, state = scenarioState)
}
step("Click on 'Add to portfolio' button") {
onMarketsScreen { addToPortfolioButton.clickWithAssertion() }
step("Click on 'Add' button in 'Markets' bottom sheet") {
onMarketsScreen { addButton.clickWithAssertion() }
}
step("Click on main network") {
onMarketsScreen { mainNetworkSuffix.performClick() }
}
step("Click on 'Add' button") {
onDialog { addButton.clickWithAssertion() }
}
step("Assert 'Continue' is not displayed") {
onDialog { addButton.assertIsNotDisplayed() }
step("Click on 'Add' button in 'Add token' bottom sheet") {
flakySafely(WAIT_UNTIL_TIMEOUT) {
onAddTokenBottomSheet {
addButton.performClick()
}
onAddTokenBottomSheet { laterButton.assertIsDisplayed() }
}
}
step("Click on 'Later' button") {
onDialog { laterButton.clickWithAssertion() }
onAddTokenBottomSheet { laterButton.performClick() }
}
step("Go back to 'Markets: tokens list'") {
step("Press 'Back' button") {
waitForIdle()
onMarketsScreen { topBarBackButton.clickWithAssertion() }
device.uiDevice.pressBack()
}
step("Close 'Markets screen'") {
onSearchBar { searchField.assertIsDisplayed() }
swipeMarketsBlock(SwipeDirection.DOWN)
step("Press 'Back' button") {
waitForIdle()
device.uiDevice.pressBack()
}
step("Assert $updatedBalance is displayed in total balance") {
onMainScreen { totalBalanceText.assertTextContains(updatedBalance) }

View file

@ -2,6 +2,8 @@ package com.tangem.tests.main
import com.tangem.common.BaseTestCase
import com.tangem.common.constants.TestConstants.USER_TOKENS_API_SCENARIO
import com.tangem.common.extensions.SwipeDirection
import com.tangem.common.extensions.swipeVertical
import com.tangem.common.utils.resetWireMockScenarioState
import com.tangem.common.utils.setWireMockScenarioState
import com.tangem.scenarios.openMainScreen
@ -35,7 +37,7 @@ class MainScreenTest : BaseTestCase() {
}
@AllureId("8748")
@DisplayName("Main: check 'Organize tokens' button with single token no accounts")
@DisplayName("Main: check 'Add & Manage' button with single token no accounts")
@Test
fun checkOrganizeTokensButtonWithSingleTokenNoAccountsTest() {
val scenarioState = "Cardano"
@ -56,14 +58,14 @@ class MainScreenTest : BaseTestCase() {
step("Synchronize addresses") {
synchronizeAddresses()
}
step("Assert 'Add & Manage' button is not displayed") {
onMainScreen { addAndManageButtonNode.assertIsNotDisplayed()}
step("Assert 'Add & Manage' button is displayed") {
onMainScreen { addAndManageButtonNode.assertIsDisplayed() }
}
}
}
@AllureId("8749")
@DisplayName("Main: check 'Organize tokens' button with single token two accounts")
@DisplayName("Main: check 'Add & Manage' button with single token two accounts")
@Test
fun checkOrganizeTokensButtonWithSingleTokenMultiAccountsTest() {
val scenarioState = "TwoAccountsSingleTokenEach"
@ -81,14 +83,14 @@ class MainScreenTest : BaseTestCase() {
step("Open 'Main Screen'") {
openMainScreen()
}
step("Assert 'Add & Manage' button is not displayed") {
onMainScreen { addAndManageButtonNode.assertIsNotDisplayed()}
step("Assert 'Add & Manage' button is displayed") {
onMainScreen { addAndManageButtonNode.assertIsDisplayed() }
}
}
}
@AllureId("8750")
@DisplayName("Main: check 'Organize tokens' button with multiple tokens two accounts")
@DisplayName("Main: check 'Add & Manage' button with multiple tokens two accounts")
@Test
fun checkOrganizeTokensButtonWithMultipleTokensMultiAccountsTest() {
val scenarioState = "TwoAccountsMixed"
@ -106,8 +108,11 @@ class MainScreenTest : BaseTestCase() {
step("Open 'Main Screen'") {
openMainScreen()
}
step("Swipe up") {
swipeVertical(SwipeDirection.UP, startHeightRatio = 0.6f, endHeightRatio = 0.1f)
}
step("Assert 'Add & Manage' button is displayed") {
onMainScreen { addAndManageButtonNode.assertIsDisplayed()}
onMainScreen { addAndManageButtonNode.assertIsDisplayed() }
}
}
}

View file

@ -16,7 +16,7 @@ import org.junit.Test
class WarningsTest : BaseTestCase() {
@AllureId("184")
@DisplayName("Token list: hide token by long tap")
@DisplayName("Warnings: missing address warning")
@Test
fun checkUnavailableNetworksWarningTest() {
val scenarioState = "MissingDerivation"
@ -38,9 +38,6 @@ class WarningsTest : BaseTestCase() {
step("Synchronize addresses") {
synchronizeAddresses(isBalanceAvailable = false)
}
step("Assert 'Missing addresses' notification icon is displayed") {
onMainScreen { missingAddressNotificationIcon.assertIsDisplayed() }
}
step("Assert 'Missing addresses' notification title is displayed") {
onMainScreen { missingAddressNotificationTitle.assertIsDisplayed() }
}

View file

@ -1,5 +1,6 @@
package com.tangem.tests.markets
import androidx.compose.ui.test.ExperimentalTestApi
import com.tangem.common.BaseTestCase
import com.tangem.common.annotations.ApiEnv
import com.tangem.common.annotations.ApiEnvConfig
@ -39,6 +40,7 @@ class MarketsExchangesTest : BaseTestCase() {
}
}
@OptIn(ExperimentalTestApi::class)
@Test
@AllureId("56")
@ApiEnv(ApiEnvConfig(ApiConfig.ID.TangemTech, ApiEnvironment.PROD))
@ -60,9 +62,8 @@ class MarketsExchangesTest : BaseTestCase() {
onMarketsScreen { tokenWithTitle(tokenName).clickWithAssertion() }
waitForIdle()
}
step("Scroll down") {
swipeVertical(SwipeDirection.UP)
swipeVertical(SwipeDirection.UP)
step("Scroll to 'Listed on exchanges' block") {
onMarketsScreen { scrollToListedOnBlock() }
}
step("Assert 'Listed on exchanges' block has title") {
onMarketsScreen { listedOnBlockContainer.assertIsDisplayed() }

View file

@ -227,7 +227,7 @@ class RecentBlockTest : BaseTestCase() {
val sendAmount = "1"
val txHistoryScenarioState = "11OutgoingTransactions"
val recipientAddressBase = "DJ2TaZ5vvp3mBLugUpKjVM3pRBLi4uYaq"
val shortenedRecipientAddress = "DJ2TaZ5vvp3mBLugU...Li4uYaq123456789b"
val longRecipientAddress = recipientAddressBase + "123456789b"
setupHooks(
additionalAfterSection = {
@ -261,7 +261,7 @@ class RecentBlockTest : BaseTestCase() {
checkRecentAddressItem(address = DOGECOIN_ADDRESS, description = recentTransactionAmount1)
}
step("Check recent address item №2") {
checkRecentAddressItem(address = shortenedRecipientAddress, description = recentTransactionAmount2)
checkRecentAddressItem(address = longRecipientAddress, description = recentTransactionAmount2)
}
step("Check recent address item №3") {
checkRecentAddressItem(address = recipientAddressBase + "k", description = recentTransactionAmount2)

View file

@ -246,8 +246,11 @@ class SendAddressScreenTest : BaseTestCase() {
step("Click on token with name: '$tokenName'") {
onMainScreen { tokenWithTitleAndAddress(tokenName).performClick() }
}
step("Click on 'Send' button") {
onTokenDetailsScreen { sendButton().performClick() }
step("Click on 'Transfer' button") {
onTokenDetailsScreen { transferButton.clickWithAssertion() }
}
step("Click on 'Send' button in bottom sheet") {
onTransferBottomSheet { sendButton.clickWithAssertion() }
}
step("Set WireMock scenario: '$scenarioName' to state: '$scenarioState'") {
setWireMockScenarioState(scenarioName = scenarioName, state = scenarioState)

View file

@ -46,8 +46,11 @@ class SendConfirmScreenTest : BaseTestCase() {
step("Click on token with name: '$tokenName'") {
onMainScreen { tokenWithTitleAndAddress(tokenName).clickWithAssertion() }
}
step("Click on 'Send' button") {
onTokenDetailsScreen { sendButton().performClick() }
step("Click on 'Transfer' button") {
onTokenDetailsScreen { transferButton.clickWithAssertion() }
}
step("Click on 'Send' button in bottom sheet") {
onTransferBottomSheet { sendButton.clickWithAssertion() }
}
step("Type '$inputAmount' in input text field") {
onSendScreen {
@ -123,8 +126,11 @@ class SendConfirmScreenTest : BaseTestCase() {
step("Click on token with name: '$tokenName'") {
onMainScreen { tokenWithTitleAndAddress(tokenName).clickWithAssertion() }
}
step("Click on 'Send' button") {
onTokenDetailsScreen { sendButton().performClick() }
step("Click on 'Transfer' button") {
onTokenDetailsScreen { transferButton.clickWithAssertion() }
}
step("Click on 'Send' button in bottom sheet") {
onTransferBottomSheet { sendButton.clickWithAssertion() }
}
step("Type '$inputAmount' in input text field") {
onSendScreen {

View file

@ -281,13 +281,13 @@ class SendFeeScreenTest : BaseTestCase() {
fun checkNetworkFeeBottomSheetForBitcoinTest() {
val tokenName = "Bitcoin"
val tokenAmount = "0.00000001"
val feeAmount = "$2.86"
val feeAmount = "$0.48"
val fiatFeeAmount = "$0.24"
val marketSelectorItem = getResourceString(R.string.common_fee_selector_option_market)
val fastSelectorItem = getResourceString(R.string.common_fee_selector_option_fast)
val slowSelectorItem = getResourceString(R.string.common_fee_selector_option_slow)
val feeUpTo = getResourceString(R.string.send_max_fee)
val feeUpToValue = "0.0000264 BTC"
val feeUpToValue = "0.0000044 BTC"
val newFeeUpToValue = "0.0000022 BTC"
val satoshi = getResourceString(R.string.send_satoshi_per_byte_title)
val satoshiValue = "2"

View file

@ -4,10 +4,12 @@ import com.tangem.common.BaseTestCase
import com.tangem.common.constants.TestConstants.KASPA_RECIPIENT_ADDRESS
import com.tangem.common.constants.TestConstants.QUOTES_API_SCENARIO
import com.tangem.common.constants.TestConstants.USER_TOKENS_API_SCENARIO
import com.tangem.common.constants.TestConstants.WAIT_UNTIL_TIMEOUT_LONG
import com.tangem.common.extensions.clickWithAssertion
import com.tangem.common.utils.resetWireMockScenarioState
import com.tangem.common.utils.setWireMockScenarioState
import com.tangem.scenarios.checkSendWarning
import com.tangem.scenarios.openSendConfirmScreenViaNextButton
import com.tangem.scenarios.openSendScreen
import com.tangem.screens.onSendAddressScreen
import com.tangem.screens.onSendScreen
@ -145,8 +147,10 @@ class KaspaWarningsTest : BaseTestCase() {
step("Type address in input text field") {
onSendAddressScreen { addressTextField.performTextReplacement(KASPA_RECIPIENT_ADDRESS) }
}
step("Click on 'Next' button") {
onSendAddressScreen { nextButton.clickWithAssertion() }
step("Click 'Next' button until 'Send Confirm' screen opens") {
composeTestRule.waitUntil(timeoutMillis = WAIT_UNTIL_TIMEOUT_LONG) {
runCatching { openSendConfirmScreenViaNextButton() }.isSuccess
}
}
step("Assert 'UTXO limit warning' is displayed") {
checkSendWarning(

View file

@ -3,8 +3,6 @@ package com.tangem.tests.swap
import androidx.compose.ui.test.longClick
import androidx.test.InstrumentationRegistry.getTargetContext
import com.tangem.common.BaseTestCase
import com.tangem.common.constants.TestConstants.WAIT_UNTIL_TIMEOUT
import com.tangem.common.extensions.assertHasBadge
import com.tangem.common.extensions.restartApp
import com.tangem.common.utils.resetWireMockScenarioState
import com.tangem.common.utils.setWireMockScenarioState
@ -18,101 +16,6 @@ import org.junit.Test
@HiltAndroidTest
class SwapStoriesTest : BaseTestCase() {
@AllureId("5453")
@DisplayName("Check 'Swap' button badge on 'Main' screen")
@Test
fun checkMainScreenSwapButtonBadgeTest() {
setupHooks().run {
step("Open 'Main Screen'") {
openMainScreen()
}
step("Synchronize addresses") {
synchronizeAddresses()
}
step("Assert 'Swap' button has badge") {
onMainScreen { swapButton.assertHasBadge() }
}
step("Open 'Swap' screen") {
openSwapScreen(from = SwapEntryPoint.MainScreen)
}
step("Click on 'Close' button") {
onSwapTokenScreen { closeButton.performClick() }
}
step("Assert 'Swap' button has not badge") {
onMainScreen { swapButton.assertHasBadge(false) }
}
}
}
@AllureId("5454")
@DisplayName("Check 'Swap' button badge on token details screen")
@Test
fun checkTokenDetailsScreenSwapButtonTest() {
val tokenName = "Ethereum"
setupHooks().run {
step("Open 'Main Screen'") {
openMainScreen()
}
step("Synchronize addresses") {
synchronizeAddresses()
}
step("Click on token with name: '$tokenName'") {
onMainScreen { tokenWithTitleAndAddress(tokenName).performClick() }
}
step("Assert 'Swap' button has badge") {
onTokenDetailsScreen { swapButton().assertHasBadge() }
}
step("Open 'Swap' screen") {
openSwapScreen(from = SwapEntryPoint.TokenDetails)
}
step("Click on 'Close' button") {
onSwapTokenScreen { closeButton.performClick() }
}
step("Assert 'Swap' button has not badge") {
onTokenDetailsScreen { swapButton().assertHasBadge(false) }
}
}
}
@AllureId("5455")
@DisplayName("Check 'Swap' button badge on token details in 'Market' screen")
@Test
fun checkMarketTokenDetailsScreenSwapButtonTest() {
val tokenName = "Ethereum"
val badgeShown = "Badge shown"
val badgeHidden = "Badge hidden"
setupHooks().run {
step("Open 'Main Screen'") {
openMainScreen()
}
step("Synchronize addresses") {
synchronizeAddresses()
}
step("Open 'Markets' token details screen for token '$tokenName'") {
openMarketTokenDetailsScreen(blockchainName = tokenName, tokenName = tokenName)
}
step("Assert 'Swap' button has badge") {
onMarketsTokenDetailsScreen { swapPortfolioQuickActionButton.assertIsDisplayed() }
onMarketsTokenDetailsScreen { swapPortfolioQuickActionButton.assertContentDescriptionEquals(badgeShown) }
}
step("Open 'Swap' screen") {
openSwapScreen(from = SwapEntryPoint.MarketsTokenDetails)
}
step("Click on 'Close' button") {
onSwapTokenScreen { closeButton.performClick() }
}
step("Assert 'Swap' button has not badge") {
onMarketsTokenDetailsScreen { swapPortfolioQuickActionButton.assertContentDescriptionEquals(badgeHidden) }
}
}
}
@AllureId("5469")
@DisplayName("Check unavailable swap stories on 'Main' screen")
@Test
@ -136,9 +39,6 @@ class SwapStoriesTest : BaseTestCase() {
step("Synchronize addresses") {
synchronizeAddresses()
}
step("Assert 'Swap' button has not badge") {
onMainScreen { swapButton.assertHasBadge(false) }
}
step("Open 'Swap' screen") {
openSwapScreen(from = SwapEntryPoint.MainScreen, storiesExist = false)
}
@ -155,9 +55,6 @@ class SwapStoriesTest : BaseTestCase() {
waitForIdle()
onMainScreen { swapButton.assertIsDisplayed() }
}
step("Assert 'Swap' button has badge") {
onMainScreen { swapButton.assertHasBadge() }
}
step("Open 'Swap' screen") {
openSwapScreen(from = SwapEntryPoint.MainScreen, storiesExist = true)
}
@ -192,9 +89,6 @@ class SwapStoriesTest : BaseTestCase() {
step("Click on token with name: '$tokenName'") {
onMainScreen { tokenWithTitleAndAddress(tokenName).performClick() }
}
step("Assert 'Swap' button has not badge") {
onTokenDetailsScreen { swapButton().assertHasBadge(false) }
}
step("Open 'Swap' screen") {
openSwapScreen(from = SwapEntryPoint.TokenDetails, storiesExist = false)
}
@ -207,13 +101,6 @@ class SwapStoriesTest : BaseTestCase() {
step("Restart app") {
restartApp(packageName)
}
step("Assert 'Swap' button has badge") {
waitForIdle()
flakySafely(WAIT_UNTIL_TIMEOUT) {
composeTestRule.mainClock.advanceTimeBy(500)
onMainScreen { swapButton.assertHasBadge() }
}
}
step("Open 'Swap' screen") {
openSwapScreen(from = SwapEntryPoint.TokenDetails, storiesExist = true)
}
@ -228,8 +115,6 @@ class SwapStoriesTest : BaseTestCase() {
val scenarioErrorState = "Error"
val packageName = getTargetContext().packageName
val tokenName = "Ethereum"
val badgeShown = "Badge shown"
val badgeHidden = "Badge hidden"
setupHooks(
additionalBeforeAppLaunchSection = {
@ -246,16 +131,15 @@ class SwapStoriesTest : BaseTestCase() {
step("Synchronize addresses") {
synchronizeAddresses()
}
step("Open 'Markets' token details screen for token '$tokenName'") {
openMarketTokenDetailsScreen(blockchainName = tokenName, tokenName = tokenName)
step("Open 'Token details' from 'Markets' screen for token '$tokenName'") {
openTokenDetailsFromMarketsScreen(blockchainName = tokenName, tokenName = tokenName)
}
step("Assert 'Swap' button has not badge") {
step("Assert 'Swap' button is displayed") {
waitForIdle()
onMarketsTokenDetailsScreen { swapPortfolioQuickActionButton.assertIsDisplayed() }
onMarketsTokenDetailsScreen { swapPortfolioQuickActionButton.assertContentDescriptionEquals(badgeHidden) }
onTokenDetailsScreen { swapButton.assertIsDisplayed() }
}
step("Open 'Swap' screen") {
openSwapScreen(from = SwapEntryPoint.MarketsTokenDetails, storiesExist = false)
openSwapScreen(from = SwapEntryPoint.TokenDetails, storiesExist = false)
}
step("Click on 'Close' button") {
onSwapTokenScreen { closeButton.performClick() }
@ -266,16 +150,12 @@ class SwapStoriesTest : BaseTestCase() {
step("Restart app") {
restartApp(packageName)
}
step("Open 'Markets' token details screen for token '$tokenName'") {
openMarketTokenDetailsScreen(blockchainName = tokenName, tokenName = tokenName)
}
step("Assert 'Swap' button has badge") {
step("Assert 'Swap' button is displayed") {
waitForIdle()
onMarketsTokenDetailsScreen { swapPortfolioQuickActionButton.assertIsDisplayed() }
onMarketsTokenDetailsScreen { swapPortfolioQuickActionButton.assertContentDescriptionEquals(badgeShown) }
onTokenDetailsScreen { swapButton.assertIsDisplayed() }
}
step("Open 'Swap' screen") {
openSwapScreen(from = SwapEntryPoint.MarketsTokenDetails, storiesExist = true)
openSwapScreen(from = SwapEntryPoint.TokenDetails, storiesExist = true)
}
}
}
@ -331,11 +211,8 @@ class SwapStoriesTest : BaseTestCase() {
step("Click on token with name: '$tokenName'") {
onMainScreen { tokenWithTitleAndAddress(tokenName).performClick() }
}
step("Assert 'Swap' button has badge") {
onTokenDetailsScreen { swapButton().assertHasBadge() }
}
step("Click on 'Swap' button on 'Token details' screen") {
onTokenDetailsScreen { swapButton().performClick() }
onTokenDetailsScreen { swapButton.performClick() }
}
step("Check stories changes") {
checkStoriesChanges()
@ -369,11 +246,11 @@ class SwapStoriesTest : BaseTestCase() {
step("Synchronize addresses") {
synchronizeAddresses()
}
step("Open 'Markets' token details screen for token '$tokenName'") {
openMarketTokenDetailsScreen(blockchainName = tokenName, tokenName = tokenName)
step("Open 'Token details' from 'Markets' screen for token '$tokenName'") {
openTokenDetailsFromMarketsScreen(blockchainName = tokenName, tokenName = tokenName)
}
step("Click on 'Swap' button on 'Markets' token details screen") {
onMarketsTokenDetailsScreen { swapPortfolioQuickActionButton.performClick() }
onTokenDetailsScreen { swapButton.performClick() }
}
step("Check stories changes") {
checkStoriesChanges()
@ -388,7 +265,7 @@ class SwapStoriesTest : BaseTestCase() {
onSwapTokenScreen { closeButton.performClick() }
}
step("Open 'Swap' screen without stories") {
openSwapScreen(from = SwapEntryPoint.MarketsTokenDetails, storiesExist = false)
openSwapScreen(from = SwapEntryPoint.TokenDetails, storiesExist = false)
}
}
}
@ -433,6 +310,17 @@ class SwapStoriesTest : BaseTestCase() {
step("Click on 'Close' button") {
onSwapTokenScreen { closeButton.performClick() }
}
step("Long click on token with name: '$tokenName' again to reopen actions menu") {
waitForIdle()
onMainScreen {
tokenWithTitleAndAddress(tokenName).performTouchInput {
longClick(
position = center,
durationMillis = 1000L,
)
}
}
}
step("Open 'Swap' screen without stories") {
openSwapScreen(from = SwapEntryPoint.TokenActionsBottomSheet, storiesExist = false)
}

View file

@ -50,7 +50,7 @@ class SwapTokenScreenTest : BaseTestCase() {
onTokenDetailsScreen { title.assertIsDisplayed() }
}
step("Click on 'Swap' button") {
onTokenDetailsScreen { swapButton().performClick() }
onTokenDetailsScreen { swapButton.performClick() }
}
step("Close 'Stories' screen") {
onSwapStoriesScreen { closeButton.clickWithAssertion() }
@ -147,7 +147,7 @@ class SwapTokenScreenTest : BaseTestCase() {
disableMobileData()
}
step("Click on 'Swap' button") {
onTokenDetailsScreen { swapButton().performClick() }
onTokenDetailsScreen { swapButton.performClick() }
}
step("Close 'Stories' screen") {
onSwapStoriesScreen { closeButton.clickWithAssertion() }
@ -201,7 +201,7 @@ class SwapTokenScreenTest : BaseTestCase() {
onTokenDetailsScreen { title.assertIsDisplayed() }
}
step("Click on 'Swap' button") {
onTokenDetailsScreen { swapButton().performClick() }
onTokenDetailsScreen { swapButton.performClick() }
}
step("Close 'Stories' screen") {
onSwapStoriesScreen { closeButton.clickWithAssertion() }
@ -304,7 +304,7 @@ class SwapTokenScreenTest : BaseTestCase() {
onTokenDetailsScreen { title.assertIsDisplayed() }
}
step("Click on 'Swap' button") {
onTokenDetailsScreen { swapButton().performClick() }
onTokenDetailsScreen { swapButton.performClick() }
}
step("Close 'Stories' screen") {
onSwapStoriesScreen { closeButton.clickWithAssertion() }
@ -510,7 +510,7 @@ class SwapTokenScreenTest : BaseTestCase() {
onMainScreen { tokenWithTitleAndAddress(polygon).clickWithAssertion() }
}
step("Assert 'Swap' button is not dimmed. Swap available") {
onTokenDetailsScreen { swapButton().assertIsDimmed(false) }
onTokenDetailsScreen { swapButton.assertIsEnabled() }
}
step("Press 'Back' button") {
device.uiDevice.pressBack()
@ -519,7 +519,7 @@ class SwapTokenScreenTest : BaseTestCase() {
onMainScreen { tokenWithTitleAndAddress(bitcoin).clickWithAssertion() }
}
step("Assert 'Swap' button is dimmed") {
onTokenDetailsScreen { swapButton().assertIsDimmed(true) }
onTokenDetailsScreen { swapButton.assertIsNotEnabled() }
}
step("Press 'Back' button") {
device.uiDevice.pressBack()
@ -528,7 +528,7 @@ class SwapTokenScreenTest : BaseTestCase() {
onMainScreen { tokenWithTitleAndAddress(salam).clickWithAssertion() }
}
step("Assert 'Swap' button is dimmed") {
onTokenDetailsScreen { swapButton().assertIsDimmed(true) }
onTokenDetailsScreen { swapButton.assertIsNotEnabled() }
}
}
}

View file

@ -78,6 +78,16 @@ internal object YieldSupplyDomainModule {
)
}
@Provides
@Singleton
fun provideWrapYieldSwapCallDataWithUpgradeUseCase(
yieldSupplyTransactionRepository: YieldSupplyTransactionRepository,
): WrapYieldSwapCallDataWithUpgradeUseCase {
return WrapYieldSwapCallDataWithUpgradeUseCase(
yieldSupplyTransactionRepository = yieldSupplyTransactionRepository,
)
}
@Provides
@Singleton
fun provideYieldSupplyGetProtocolBalanceUseCase(

View file

@ -16,6 +16,7 @@ import androidx.compose.ui.draw.BlurredEdgeTreatment
import androidx.compose.ui.draw.blur
import androidx.compose.ui.draw.clip
import androidx.compose.ui.draw.innerShadow
import androidx.compose.ui.platform.testTag
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.StrokeCap
import androidx.compose.ui.graphics.shadow.Shadow
@ -38,6 +39,7 @@ import com.tangem.core.ui.ds.row.TangemRowLayoutId
import com.tangem.core.ui.extensions.*
import com.tangem.core.ui.res.TangemTheme
import com.tangem.core.ui.res.TangemThemePreviewRedesign
import com.tangem.core.ui.test.TokenDetailsScreenTestTags
import com.tangem.core.res.R as CoreResR
private const val TINTED_BACKGROUND_ALPHA = 0.1f
@ -179,17 +181,23 @@ private fun EarnBlockTrailing(type: Type, trailingUM: EarnBlockUM.TrailingUM?, o
}
is EarnBlockUM.TrailingUM.Balance -> {
if (!trailingUM.isBalanceHidden) {
val fiatModifier = Modifier.layoutId(TangemRowLayoutId.END_TOP).let {
if (type == Type.Staking) it.testTag(TokenDetailsScreenTestTags.STAKING_FIAT_AMOUNT) else it
}
val cryptoModifier = Modifier.layoutId(TangemRowLayoutId.END_BOTTOM).let {
if (type == Type.Staking) it.testTag(TokenDetailsScreenTestTags.STAKING_TOKEN_AMOUNT) else it
}
Text(
text = trailingUM.fiatValue.resolveAnnotatedReference(),
style = TangemTheme.typography2.bodySemibold16,
color = TangemTheme.colors2.text.neutral.primary,
modifier = Modifier.layoutId(TangemRowLayoutId.END_TOP),
modifier = fiatModifier,
)
Text(
text = trailingUM.cryptoValue.resolveReference(),
style = TangemTheme.typography2.captionMedium12,
color = TangemTheme.colors2.text.neutral.secondary,
modifier = Modifier.layoutId(TangemRowLayoutId.END_BOTTOM),
modifier = cryptoModifier,
)
}
}

View file

@ -18,6 +18,7 @@ import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.platform.testTag
import androidx.compose.ui.res.painterResource
import androidx.compose.ui.tooling.preview.Preview
import com.tangem.common.ui.R
@ -35,6 +36,7 @@ import com.tangem.core.ui.extensions.resolveReference
import com.tangem.core.ui.extensions.stringReference
import com.tangem.core.ui.res.TangemTheme
import com.tangem.core.ui.res.TangemThemePreviewRedesign
import com.tangem.core.ui.test.TokenDetailsScreenTestTags
import kotlinx.collections.immutable.PersistentList
import kotlinx.collections.immutable.persistentListOf
@ -78,7 +80,8 @@ private fun ExpressTransactionItem(
.clip(TangemTheme.shapes.roundedCornersXMedium)
.background(TangemTheme.colors2.surface.level3)
.clickable(onClick = info.onClick)
.padding(TangemTheme.dimens2.x4),
.padding(TangemTheme.dimens2.x4)
.testTag(TokenDetailsScreenTestTags.EXPRESS_STATUS_ITEM),
) {
TitleRow(
title = info.title.resolveReference(),
@ -104,7 +107,9 @@ private fun TitleRow(title: String, infoIconRes: Int?, infoIconTint: Color?) {
text = title,
style = TangemTheme.typography2.bodyMedium16,
color = TangemTheme.colors3.text.primary,
modifier = Modifier.weight(1f),
modifier = Modifier
.weight(1f)
.testTag(TokenDetailsScreenTestTags.EXPRESS_STATUS_ITEM_TITLE),
)
if (infoIconRes != null && infoIconTint != null) {
Icon(
@ -126,32 +131,42 @@ private fun AmountsRow(info: ExpressTransactionStateInfoUM) {
CurrencyIcon(
state = info.fromCurrencyIcon,
shouldDisplayNetwork = false,
modifier = Modifier.size(TangemTheme.dimens.size18),
modifier = Modifier
.size(TangemTheme.dimens.size18)
.testTag(TokenDetailsScreenTestTags.EXPRESS_STATUS_ITEM_FROM_ICON),
)
EllipsisText(
text = info.fromAmount.resolveReference(),
style = TangemTheme.typography2.bodyMedium16,
color = TangemTheme.colors3.text.primary,
ellipsis = TextEllipsis.OffsetEnd(info.fromAmountSymbol.length),
modifier = Modifier.weight(weight = 1f, fill = false),
modifier = Modifier
.weight(weight = 1f, fill = false)
.testTag(TokenDetailsScreenTestTags.EXPRESS_STATUS_ITEM_FROM_AMOUNT),
)
Icon(
painter = painterResource(R.drawable.ic_forward_24),
contentDescription = null,
tint = TangemTheme.colors3.icon.tertiary,
modifier = Modifier.size(TangemTheme.dimens.size18),
modifier = Modifier
.size(TangemTheme.dimens.size18)
.testTag(TokenDetailsScreenTestTags.EXPRESS_STATUS_ITEM_SWAP_ICON),
)
CurrencyIcon(
state = info.toCurrencyIcon,
shouldDisplayNetwork = false,
modifier = Modifier.size(TangemTheme.dimens.size18),
modifier = Modifier
.size(TangemTheme.dimens.size18)
.testTag(TokenDetailsScreenTestTags.EXPRESS_STATUS_ITEM_TO_ICON),
)
EllipsisText(
text = info.toAmount.resolveReference(),
style = TangemTheme.typography2.bodyMedium16,
color = TangemTheme.colors3.text.primary,
ellipsis = TextEllipsis.OffsetEnd(info.toAmountSymbol.length),
modifier = Modifier.weight(weight = 1f, fill = false),
modifier = Modifier
.weight(weight = 1f, fill = false)
.testTag(TokenDetailsScreenTestTags.EXPRESS_STATUS_ITEM_TO_AMOUNT),
)
}
}

View file

@ -19,6 +19,8 @@ import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.vector.ImageVector
import androidx.compose.ui.layout.layoutId
import androidx.compose.ui.res.vectorResource
import androidx.compose.ui.semantics.disabled
import androidx.compose.ui.semantics.semantics
import androidx.compose.ui.unit.dp
import com.tangem.core.ui.R
import com.tangem.core.ui.ds.row.TangemRowContainer
@ -68,7 +70,8 @@ fun TokenActionRow(
onClick = onClick,
onLongClick = onLongClick,
hapticManager = hapticManager,
),
)
.semantics { if (!isEnabled) disabled() },
) {
LeadingIcon(iconRes = iconRes, accentColor = accentColor)
Text(

View file

@ -55,6 +55,10 @@
"name": "WALLET_CONNECT_BITCOIN_ENABLED",
"version": "undefined"
},
{
"name": "TWI_1326_YIELD_MODE_SWAP_ENABLED",
"version": "undefined"
},
{
"name": "ADDRESS_SYNC_ENABLED",
"version": "undefined"

View file

@ -0,0 +1,45 @@
package com.tangem.datasource.api.auth
import com.tangem.datasource.api.auth.models.request.AuthApiRequest
import com.tangem.datasource.api.auth.models.request.NonceApiRequest
import com.tangem.datasource.api.auth.models.request.RefreshApiRequest
import com.tangem.datasource.api.auth.models.response.NonceApiResponse
import com.tangem.datasource.api.auth.models.response.TokenApiResponse
import com.tangem.datasource.api.common.response.ApiResponse
import retrofit2.http.Body
import retrofit2.http.POST
/**
* Tangem Auth Service API (JWT session tokens / DPoP interceptor / refresh rotation)
*/
interface AuthApi {
/**
* Request authentication nonce.
*
* Generates a nonce bound to the device public key for the authentication flow.
*/
@POST("api/v1/auth/nonce/auth")
suspend fun requestAuthNonce(@Body request: NonceApiRequest): ApiResponse<NonceApiResponse>
/**
* Authenticate device.
*
* Authenticates a previously registered device using a device-key signature. Issues a new
* JWT access token with bound `walletIds[]` and risk tier. All subsequent auth after
* registration uses this endpoint.
*/
@POST("api/v1/auth/authenticate")
suspend fun authenticate(@Body request: AuthApiRequest): ApiResponse<TokenApiResponse>
/**
* Refresh tokens.
*
* Rotates the refresh token and issues a new access token. Uses refresh-token rotation
* with family-based reuse detection replaying a consumed token revokes the entire token
* family (SR-8). Sender-constraint is verified via the DPoP-proof header (`cnf.jkt`).
*/
@POST("api/v1/auth/refresh")
@RequiresSessionAuth
suspend fun refresh(@Body request: RefreshApiRequest): ApiResponse<TokenApiResponse>
}

View file

@ -0,0 +1,16 @@
package com.tangem.datasource.api.auth
/**
* Marks a Retrofit endpoint as requiring an authenticated session (DPoP, see
* [RFC 9449](https://www.rfc-editor.org/rfc/rfc9449)).
*
* Read at runtime by the session-auth interceptor: only methods
* carrying this annotation receive `Authorization: DPoP <access-token>` + `DPoP: <proof-jwt>`
* headers; unannotated methods (e.g. public nonce endpoints) pass through unchanged.
*
* Mirrors the per-operation `security` blocks in the backend OpenAPI contract; follows the
* same on-method annotation pattern as `@ReadTimeout` / `@ConnectTimeout`.
*/
@Target(AnnotationTarget.FUNCTION)
@Retention(AnnotationRetention.RUNTIME)
annotation class RequiresSessionAuth

View file

@ -0,0 +1,46 @@
package com.tangem.datasource.api.auth.models.request
import com.squareup.moshi.Json
import com.squareup.moshi.JsonClass
/** Authentication request — authenticates a previously registered device. */
@JsonClass(generateAdapter = true)
data class AuthApiRequest(
/** Signed authentication payload. */
@Json(name = "payload") val payload: AuthenticationPayload,
/** EC signature over the authentication payload, signed by the device private key (Base64). */
@Json(name = "signature") val signature: String,
)
/** Signed authentication payload — the data that is signed by the device private key. */
@JsonClass(generateAdapter = true)
data class AuthenticationPayload(
/** Base64-encoded EC public key of the device (e.g. `MFkwEwYHKoZIzj0CAQYIKoZIzj0DAQcDQgAE...`). */
@Json(name = "devicePublicKey") val devicePublicKey: String,
/** Deciphered nonce value from the nonce endpoint. */
@Json(name = "nonce") val nonce: String,
/** Platform attestation token (Play Integrity / App Attest). */
@Json(name = "attestationToken") val attestationToken: String?,
/** Client-reported device metadata. */
@Json(name = "metadata") val metadata: DeviceMetadata,
) {
/** Device metadata collection. */
@JsonClass(generateAdapter = true)
data class DeviceMetadata(
/** Device hardware model (e.g. `iPhone 15 Pro`). */
@Json(name = "deviceModel") val deviceModel: String?,
/** Operating system (`android` / `ios`). */
@Json(name = "os") val os: String,
/** OS version string (e.g. `17.4.1`). */
@Json(name = "osVersion") val osVersion: String?,
/** Application version (e.g. `5.8.0`). */
@Json(name = "appVersion") val appVersion: String?,
/** User-Agent header (e.g. `Tangem/5.8.0 (iPhone; iOS 17.4.1; Scale/3.00)`). */
@Json(name = "userAgent") val userAgent: String?,
/** Client locale (e.g. `en-US`). */
@Json(name = "locale") val locale: String?,
/** Client timezone (e.g. `Europe/Moscow`). */
@Json(name = "timezone") val timezone: String?,
)
}

View file

@ -0,0 +1,11 @@
package com.tangem.datasource.api.auth.models.request
import com.squareup.moshi.Json
import com.squareup.moshi.JsonClass
/** Request body for nonce generation (auth, upgrade, wallet flows). */
@JsonClass(generateAdapter = true)
data class NonceApiRequest(
/** Base64-encoded EC public key of the device (e.g. `MFkwEwYHKoZIzj0CAQYIKoZIzj0DAQcDQgAE...`). */
@Json(name = "devicePublicKey") val devicePublicKey: String,
)

View file

@ -0,0 +1,11 @@
package com.tangem.datasource.api.auth.models.request
import com.squareup.moshi.Json
import com.squareup.moshi.JsonClass
/** Token refresh request. */
@JsonClass(generateAdapter = true)
data class RefreshApiRequest(
/** Refresh token from a previous token response. */
@Json(name = "refreshToken") val refreshToken: String,
)

View file

@ -0,0 +1,13 @@
package com.tangem.datasource.api.auth.models.response
import com.squareup.moshi.Json
import com.squareup.moshi.JsonClass
/** Ciphered nonce response. */
@JsonClass(generateAdapter = true)
data class NonceApiResponse(
/** RSA-OAEP ciphered nonce value (Base64). */
@Json(name = "cipheredNonce") val cipheredNonce: String,
/** Nonce expiration timestamp (ISO-8601). */
@Json(name = "expiresAt") val expiresAt: String,
)

View file

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

View file

@ -0,0 +1,19 @@
package com.tangem.datasource.api.auth.models.response
import com.squareup.moshi.Json
import com.squareup.moshi.JsonClass
/** Token response — contains JWT access token and optional refresh token. */
@JsonClass(generateAdapter = true)
data class TokenApiResponse(
/** JWT access token (HMAC-SHA256 signed). */
@Json(name = "accessToken") val accessToken: String,
/** Access token expiration timestamp (ISO-8601). */
@Json(name = "accessTokenExpiresAt") val accessTokenExpiresAt: String,
/** Refresh token for token rotation. `null` for ORANGE tier (requires device challenge each time). */
@Json(name = "refreshToken") val refreshToken: String?,
/** Refresh token expiration timestamp (ISO-8601). `null` iff [refreshToken] is `null`. */
@Json(name = "refreshTokenExpiresAt") val refreshTokenExpiresAt: String?,
/** List of wallet IDs bound to this device. */
@Json(name = "walletIds") val walletIds: List<String>,
)

View file

@ -33,6 +33,7 @@ sealed class ApiConfig {
News,
GaslessTxService,
SurveySparrow,
Auth,
}
private fun initializeId(): ID {
@ -49,6 +50,7 @@ sealed class ApiConfig {
is News -> ID.News
is GaslessTxService -> ID.GaslessTxService
is SurveySparrow -> ID.SurveySparrow
is Auth -> ID.Auth
}
}

View file

@ -0,0 +1,59 @@
package com.tangem.datasource.api.common.config
import com.tangem.datasource.BuildConfig
/**
* Tangem Auth Service [ApiConfig] endpoints for device registration, authentication,
* nonce issuance, refresh token rotation, and JWKS publication.
*/
internal class Auth : ApiConfig() {
override val defaultEnvironment: ApiEnvironment = getInitialEnvironment()
override val environmentConfigs: List<ApiEnvironmentConfig> = listOf(
createDevEnvironment(),
createMockedEnvironment(),
createProdEnvironment(),
)
private fun getInitialEnvironment(): ApiEnvironment {
return when (BuildConfig.BUILD_TYPE) {
MOCKED_BUILD_TYPE -> ApiEnvironment.MOCK
DEBUG_BUILD_TYPE,
INTERNAL_BUILD_TYPE,
-> ApiEnvironment.DEV
EXTERNAL_BUILD_TYPE,
RELEASE_BUILD_TYPE,
-> ApiEnvironment.PROD
else -> error("Unknown build type [${BuildConfig.BUILD_TYPE}]")
}
}
private fun createDevEnvironment(): ApiEnvironmentConfig = ApiEnvironmentConfig(
environment = ApiEnvironment.DEV,
baseUrl = DEV_BASE_URL,
headers = emptyMap(),
)
private fun createMockedEnvironment(): ApiEnvironmentConfig = ApiEnvironmentConfig(
environment = ApiEnvironment.MOCK,
baseUrl = MOCK_BASE_URL,
headers = emptyMap(),
)
private fun createProdEnvironment(): ApiEnvironmentConfig = ApiEnvironmentConfig(
environment = ApiEnvironment.PROD,
baseUrl = PROD_BASE_URL,
headers = emptyMap(),
)
private companion object {
// TODO Replace with real Auth Service hosts once the backend team confirms deployment.
// Swagger currently only declares `http://localhost:8080` for local development.
// [REDACTED_JIRA]
private const val DEV_BASE_URL = "http://localhost:8080/"
private const val MOCK_BASE_URL = "http://localhost:8080/"
private const val PROD_BASE_URL = "http://localhost:8080/"
}
}

View file

@ -113,4 +113,10 @@ internal object ApiConfigsModule {
fun provideSurveySparrowConfig(environmentConfig: EnvironmentConfig): ApiConfig {
return SurveySparrow(environmentConfig)
}
@Provides
@IntoSet
fun provideAuthConfig(): ApiConfig {
return Auth()
}
}

View file

@ -1,6 +1,7 @@
package com.tangem.datasource.di
import com.tangem.datasource.BuildConfig
import com.tangem.datasource.api.auth.AuthApi
import com.tangem.datasource.api.common.blockaid.BlockAidApi
import com.tangem.datasource.api.surveysparrow.SurveySparrowApi
import com.tangem.datasource.api.common.config.ApiConfig
@ -208,6 +209,15 @@ internal object NetworkModule {
)
}
@Provides
@Singleton
fun provideAuthApi(retrofitApiBuilder: RetrofitApiBuilder): AuthApi {
return retrofitApiBuilder.build(
apiConfigId = ApiConfig.ID.Auth,
applyTimeoutAnnotations = false,
)
}
@Provides
@Singleton
fun provideGaslessTxServiceApi(retrofitApiBuilder: RetrofitApiBuilder): GaslessTxServiceApi {

View file

@ -88,6 +88,7 @@ class ApiConfigTest {
appInfoProvider = mockk(),
)
ApiConfig.ID.SurveySparrow -> SurveySparrow(environmentConfig = environmentConfig)
ApiConfig.ID.Auth -> Auth()
}
}
}

View file

@ -130,6 +130,7 @@ internal class ProdApiConfigsManagerTest {
appInfoProvider = appInfoProvider,
)
ApiConfig.ID.SurveySparrow -> SurveySparrow(environmentConfig = environmentConfig)
ApiConfig.ID.Auth -> Auth()
}
}
}
@ -148,9 +149,32 @@ internal class ProdApiConfigsManagerTest {
ApiConfig.ID.News -> createNewsModel()
ApiConfig.ID.GaslessTxService -> createGaslessTxServiceModel()
ApiConfig.ID.SurveySparrow -> createSurveySparrowModel()
ApiConfig.ID.Auth -> createAuthModel()
}
}
private fun createAuthModel(): TestModel {
val environment = when (BuildConfig.BUILD_TYPE) {
MOCKED_BUILD_TYPE -> ApiEnvironment.MOCK
DEBUG_BUILD_TYPE,
INTERNAL_BUILD_TYPE,
-> ApiEnvironment.DEV
EXTERNAL_BUILD_TYPE,
RELEASE_BUILD_TYPE,
-> ApiEnvironment.PROD
else -> error("Unknown build type [${BuildConfig.BUILD_TYPE}]")
}
return TestModel(
id = ApiConfig.ID.Auth,
expected = ApiEnvironmentConfig(
environment = environment,
baseUrl = "http://localhost:8080/",
headers = emptyMap(),
),
)
}
private fun createExpressModel(): TestModel {
val environment = when (BuildConfig.BUILD_TYPE) {
DEBUG_BUILD_TYPE,

View file

@ -16,6 +16,7 @@ import androidx.compose.ui.graphics.vector.ImageVector
import androidx.compose.ui.graphics.vector.rememberVectorPainter
import androidx.compose.ui.layout.onGloballyPositioned
import androidx.compose.ui.platform.LocalDensity
import androidx.compose.ui.platform.testTag
import androidx.compose.ui.res.vectorResource
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.tooling.preview.Preview
@ -39,6 +40,7 @@ import com.tangem.core.ui.res.LocalBottomSheetAlwaysVisible
import com.tangem.core.ui.res.LocalWindowSize
import com.tangem.core.ui.res.TangemTheme
import com.tangem.core.ui.res.TangemThemePreviewRedesign
import com.tangem.core.ui.test.BaseBottomSheetTestTags
import com.tangem.core.ui.utils.WindowInsetsZero
/**
@ -253,7 +255,8 @@ inline fun <reified T : TangemBottomSheetConfigContent> BasicBottomSheet(
Column(
modifier = contentModifier
.background(containerColor)
.heightIn(max = maxHeight),
.heightIn(max = maxHeight)
.testTag(BaseBottomSheetTestTags.CONTAINER),
) {
Box(modifier = Modifier.fillMaxWidth()) {
title(model)

View file

@ -21,6 +21,7 @@ import androidx.compose.ui.input.nestedscroll.NestedScrollConnection
import androidx.compose.ui.input.nestedscroll.NestedScrollSource
import androidx.compose.ui.input.nestedscroll.nestedScroll
import androidx.compose.ui.platform.LocalConfiguration
import androidx.compose.ui.platform.testTag
import androidx.compose.ui.res.vectorResource
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.tooling.preview.Preview
@ -40,6 +41,7 @@ import com.tangem.core.ui.components.sheetscaffold.TangemSheetState
import com.tangem.core.ui.components.sheetscaffold.TangemSheetValue
import com.tangem.core.ui.components.sheetscaffold.rememberSheetState
import com.tangem.core.ui.res.*
import com.tangem.core.ui.test.BaseBottomSheetTestTags
import com.tangem.core.ui.utils.WindowInsetsZero
const val MODAL_SHEET_MAX_HEIGHT = 0.8f
@ -205,7 +207,8 @@ inline fun <reified T : TangemBottomSheetConfigContent> BsContent(
.background(containerColor)
.heightIn(max = maxHeight.dp)
.fillMaxWidth()
.nestedScroll(nestedScrollConnection),
.nestedScroll(nestedScrollConnection)
.testTag(BaseBottomSheetTestTags.CONTAINER),
) {
Box(modifier = Modifier.fillMaxWidth()) {
title(model)

View file

@ -10,6 +10,9 @@ import androidx.compose.runtime.Composable
import androidx.compose.runtime.key
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.testTag
import androidx.compose.ui.semantics.disabled
import androidx.compose.ui.semantics.semantics
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.tooling.preview.PreviewParameter
import androidx.compose.ui.tooling.preview.PreviewParameterProvider
@ -25,6 +28,7 @@ import com.tangem.core.ui.extensions.resolveReference
import com.tangem.core.ui.extensions.stringReference
import com.tangem.core.ui.res.TangemTheme
import com.tangem.core.ui.res.TangemThemePreviewRedesign
import com.tangem.core.ui.test.BaseActionButtonsBlockTestTags
import kotlinx.collections.immutable.ImmutableList
import kotlinx.collections.immutable.persistentListOf
@ -51,7 +55,10 @@ fun ActionButtons(buttons: ImmutableList<TangemButtonUM>, modifier: Modifier = M
TangemTheme.colors2.text.status.disabled
}
Column(
modifier = Modifier.padding(horizontal = TangemTheme.dimens2.x2_5),
modifier = Modifier
.padding(horizontal = TangemTheme.dimens2.x2_5)
.testTag(BaseActionButtonsBlockTestTags.ACTION_BUTTON)
.semantics { if (!button.isEnabled) disabled() },
verticalArrangement = Arrangement.spacedBy(TangemTheme.dimens2.x2),
horizontalAlignment = Alignment.CenterHorizontally,
) {

View file

@ -19,9 +19,11 @@ import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.tooling.preview.PreviewParameter
import androidx.compose.ui.tooling.preview.PreviewParameterProvider
import androidx.compose.ui.platform.testTag
import androidx.compose.ui.unit.dp
import androidx.compose.ui.util.fastForEach
import com.tangem.core.ui.R
import com.tangem.core.ui.test.NotificationTestTags
import com.tangem.core.ui.components.notifications.NotificationConfig
import com.tangem.core.ui.components.notifications.NotificationConfig.ButtonsState
import com.tangem.core.ui.ds.button.*
@ -66,7 +68,8 @@ fun TangemMessage(
Alignment.Top
},
)
.size(messageUM.iconSize),
.size(messageUM.iconSize)
.testTag(NotificationTestTags.ICON),
)
}
},
@ -115,14 +118,14 @@ fun TangemMessage(
Image(
painter = painterResource(config.iconResId),
contentDescription = null,
modifier = Modifier.size(config.iconSize),
modifier = Modifier.size(config.iconSize).testTag(NotificationTestTags.ICON),
)
} else {
Icon(
imageVector = ImageVector.vectorResource(config.iconResId),
contentDescription = null,
tint = iconTint,
modifier = Modifier.size(config.iconSize),
modifier = Modifier.size(config.iconSize).testTag(NotificationTestTags.ICON),
)
}
},
@ -166,7 +169,7 @@ fun TangemMessage(
} else {
Alignment.Start
}
Box(modifier = modifier) {
Box(modifier = modifier.testTag(NotificationTestTags.CONTAINER)) {
Box(
modifier = Modifier
.matchParentSize()
@ -244,6 +247,7 @@ private fun TangemMessageContent(
color = TangemTheme.colors2.text.neutral.primary,
maxLines = 1,
textAlign = textAlign,
modifier = Modifier.testTag(NotificationTestTags.TITLE),
)
}
if (subtitle != null) {
@ -252,6 +256,7 @@ private fun TangemMessageContent(
text = subtitle.resolveAnnotatedReference(),
style = TangemTheme.typography2.captionSemibold12,
color = TangemTheme.colors2.text.neutral.secondary,
modifier = Modifier.testTag(NotificationTestTags.MESSAGE),
)
}
}

View file

@ -1,6 +1,7 @@
package com.tangem.core.ui.test
object BaseBottomSheetTestTags {
const val CONTAINER = "BASE_BOTTOM_SHEET_CONTAINER"
const val ACTION_BUTTON = "BASE_BOTTOM_SHEET_ACTION_BUTTON"
const val ACTION_ICON = "BASE_BOTTOM_SHEET_ACTION_ICON"
const val TITLE = "BASE_BOTTOM_SHEET_TITLE"

View file

@ -4,4 +4,6 @@ object MarketsTestTags {
const val TOKENS_LIST = "MARKETS_TOKENS_LIST"
const val TOKENS_LIST_ITEM = "MARKETS_TOKENS_LIST_ITEM"
const val LISTED_ON_EXCHANGES_COUNT = "MARKETS_LISTED_ON_EXCHANGES_COUNT"
const val LISTED_ON_BLOCK = "MARKETS_LISTED_ON_BLOCK"
const val TOKEN_DETAILS_CONTENT = "MARKETS_TOKEN_DETAILS_CONTENT"
}

View file

@ -2,6 +2,7 @@ package com.tangem.core.ui.test
object OrganizeTokensScreenTestTags {
// region OrganizeTokensTopBar
const val MENU_BUTTON = "ORGANIZE_TOKENS_MENU_BUTTON"
const val GROUP_BUTTON = "ORGANIZE_TOKENS_GROUP_BUTTON"
const val SORT_BY_BALANCE_BUTTON = "SORT_BY_BALANCE_BUTTON"
// endregion OrganizeTokensTopBar

View file

@ -46,6 +46,9 @@ dependencies {
exclude(module = "joda-time")
}
/** Core */
implementation(projects.core.configToggles)
/** Libs */
implementation(projects.libs.blockchainSdk)
implementation(projects.libs.crypto)

View file

@ -6,6 +6,8 @@ import arrow.core.toOption
import com.squareup.moshi.Moshi
import com.tangem.blockchain.common.Blockchain
import com.tangem.blockchainsdk.utils.toNetworkId
import com.tangem.core.configtoggle.FeatureToggles
import com.tangem.core.configtoggle.feature.FeatureTogglesManager
import com.tangem.data.common.api.safeApiCall
import com.tangem.data.swap.converter.SwapDataConverter
import com.tangem.data.swap.converter.SwapStatusConverter
@ -57,6 +59,7 @@ internal class DefaultSwapRepositoryV2 @Inject constructor(
private val dataSignatureVerifier: DataSignatureVerifier,
private val singleQuoteStatusSupplier: SingleQuoteStatusSupplier,
private val singleQuoteStatusFetcher: SingleQuoteStatusFetcher,
private val featureTogglesManager: FeatureTogglesManager,
@NetworkMoshi moshi: Moshi,
) : SwapRepositoryV2 {
@ -544,16 +547,21 @@ internal class DefaultSwapRepositoryV2 @Inject constructor(
return setScale(decimals, RoundingMode.HALF_DOWN).movePointRight(decimals).toPlainString()
}
private fun List<ExpressProvider>.filterYieldSupplyProvider(cryptoCurrencyStatus: CryptoCurrencyStatus?) =
filter { provider ->
// !!!WARNING!!! Filter out dex provider if yield supply is active
val yieldSupplyStatus = cryptoCurrencyStatus?.value?.yieldSupplyStatus
if (yieldSupplyStatus != null && yieldSupplyStatus.isActive) {
provider.type == ExpressProviderType.CEX
} else {
true
private fun List<ExpressProvider>.filterYieldSupplyProvider(
cryptoCurrencyStatus: CryptoCurrencyStatus?,
): List<ExpressProvider> {
return if (featureTogglesManager.isFeatureEnabled(FeatureToggles.TWI_1326_YIELD_MODE_SWAP_ENABLED)) {
this
} else {
filter { provider ->
if (cryptoCurrencyStatus?.value?.yieldSupplyStatus?.isActive == true) {
provider.type == ExpressProviderType.CEX
} else {
true
}
}
}
}
}
private val MEMO_RESTRICTED_NETWORKS = setOf(

View file

@ -18,6 +18,7 @@ import com.tangem.domain.quotes.single.SingleQuoteStatusFetcher
import com.tangem.domain.quotes.single.SingleQuoteStatusSupplier
import com.tangem.domain.swap.SwapErrorResolver
import com.tangem.domain.swap.SwapRepositoryV2
import com.tangem.core.configtoggle.feature.FeatureTogglesManager
import com.tangem.domain.swap.SwapTransactionRepository
import com.tangem.utils.coroutines.CoroutineDispatcherProvider
import dagger.Module
@ -49,6 +50,7 @@ internal object SwapDataModule {
dataSignatureVerifier: DataSignatureVerifier,
singleQuoteStatusSupplier: SingleQuoteStatusSupplier,
singleQuoteStatusFetcher: SingleQuoteStatusFetcher,
featureTogglesManager: FeatureTogglesManager,
@NetworkMoshi moshi: Moshi,
): SwapRepositoryV2 {
return DefaultSwapRepositoryV2(
@ -60,6 +62,7 @@ internal object SwapDataModule {
moshi = moshi,
singleQuoteStatusSupplier = singleQuoteStatusSupplier,
singleQuoteStatusFetcher = singleQuoteStatusFetcher,
featureTogglesManager = featureTogglesManager,
)
}

View file

@ -24,6 +24,8 @@ import com.tangem.domain.swap.models.SwapAmountType
import com.tangem.domain.swap.models.SwapCurrencyStatus
import com.tangem.domain.swap.models.SwapStatus
import com.tangem.domain.swap.models.SwapTxType
import com.tangem.core.configtoggle.FeatureToggles
import com.tangem.core.configtoggle.feature.FeatureTogglesManager
import com.tangem.utils.coroutines.TestingCoroutineDispatcherProvider
import io.mockk.*
import kotlinx.coroutines.test.runTest
@ -43,6 +45,9 @@ internal class DefaultSwapRepositoryV2Test {
private val singleQuoteStatusSupplier: SingleQuoteStatusSupplier = mockk()
private val singleQuoteStatusFetcher: SingleQuoteStatusFetcher = mockk()
private val moshi: Moshi = Moshi.Builder().build()
private val featureTogglesManager: FeatureTogglesManager = mockk {
every { isFeatureEnabled(any()) } returns false
}
private val repository = DefaultSwapRepositoryV2(
tangemExpressApi = tangemExpressApi,
@ -52,6 +57,7 @@ internal class DefaultSwapRepositoryV2Test {
dataSignatureVerifier = dataSignatureVerifier,
singleQuoteStatusSupplier = singleQuoteStatusSupplier,
singleQuoteStatusFetcher = singleQuoteStatusFetcher,
featureTogglesManager = featureTogglesManager,
moshi = moshi,
)
@ -64,7 +70,9 @@ internal class DefaultSwapRepositoryV2Test {
dataSignatureVerifier,
singleQuoteStatusSupplier,
singleQuoteStatusFetcher,
featureTogglesManager,
)
every { featureTogglesManager.isFeatureEnabled(any()) } returns false
}
// region getPairs(SwapCurrencyStatus, SwapCurrencyStatus)
@ -511,8 +519,9 @@ internal class DefaultSwapRepositoryV2Test {
// region filterYieldSupplyProvider
@Test
fun `getPairs filters out DEX providers when yield supply is active`() = runTest {
fun `getPairs filters out DEX providers when yield supply is active and flag is off`() = runTest {
// Arrange
every { featureTogglesManager.isFeatureEnabled(FeatureToggles.TWI_1326_YIELD_MODE_SWAP_ENABLED) } returns false
val primaryStatus = createCryptoCurrencyStatusWithActiveYield(primaryCoin)
val secondaryStatus = createCryptoCurrencyStatus(secondaryCoin)
val primarySwapCurrencyStatus = SwapCurrencyStatus(
@ -558,6 +567,54 @@ internal class DefaultSwapRepositoryV2Test {
assertThat(providers.first().type).isEqualTo(ExpressProviderType.CEX)
}
@Test
fun `getPairs keeps DEX providers when yield supply is active and flag is on`() = runTest {
// Arrange
every { featureTogglesManager.isFeatureEnabled(FeatureToggles.TWI_1326_YIELD_MODE_SWAP_ENABLED) } returns true
val primaryStatus = createCryptoCurrencyStatusWithActiveYield(primaryCoin)
val secondaryStatus = createCryptoCurrencyStatus(secondaryCoin)
val primarySwapCurrencyStatus = SwapCurrencyStatus(
userWallet = userWallet,
status = primaryStatus,
account = mockk(),
)
val secondarySwapCurrencyStatus = SwapCurrencyStatus(
userWallet = userWallet,
status = secondaryStatus,
account = mockk(),
)
val swapPair = SwapPair(
from = LeastTokenInfo(contractAddress = "0", network = ETH_BACKEND_ID),
to = LeastTokenInfo(contractAddress = "0", network = BTC_BACKEND_ID),
providers = listOf(
SwapPairProvider(providerId = PROVIDER_ID, rateTypes = listOf(RateType.FLOAT)),
SwapPairProvider(providerId = CEX_PROVIDER_ID, rateTypes = listOf(RateType.FLOAT)),
),
)
coEvery {
tangemExpressApi.getPairs(any(), any(), any())
} returns ApiResponse.Success(listOf(swapPair))
coEvery {
expressRepository.getProviders(any(), any())
} returns listOf(dexProvider, cexProvider)
// Act
val result = repository.getPairs(
primarySwapCurrencyStatus = primarySwapCurrencyStatus,
secondarySwapCurrencyStatus = secondarySwapCurrencyStatus,
filterProviderTypes = emptyList(),
swapTxType = SwapTxType.Swap,
)
// Assert — both providers should remain
assertThat(result).hasSize(2)
val providers = result.first().providers
assertThat(providers).hasSize(2)
}
// endregion
// region getSwapData

View file

@ -7,13 +7,13 @@ import com.tangem.datasource.api.common.config.ApiConfig
import com.tangem.datasource.api.common.config.ApiEnvironment
import com.tangem.datasource.api.common.config.managers.ApiConfigsManager
import com.tangem.domain.models.account.CardDisplayName
import com.tangem.domain.models.pay.TangemPayCardFrozenState
import com.tangem.domain.models.wallet.UserWalletId
import com.tangem.domain.pay.model.SetPinResult
import com.tangem.domain.pay.model.TangemPayCardBalance
import com.tangem.domain.pay.model.TangemPayCardDetails
import com.tangem.domain.pay.model.TangemPayOrderInfo
import com.tangem.domain.pay.repository.TangemPayCardDetailsRepository
import com.tangem.domain.models.pay.TangemPayCardFrozenState
import kotlinx.coroutines.flow.Flow
import javax.inject.Inject
import javax.inject.Singleton

View file

@ -0,0 +1,54 @@
package com.tangem.data.yield.supply
import com.tangem.blockchain.blockchains.ethereum.EthereumUtils
import com.tangem.blockchainsdk.utils.toBlockchain
import com.tangem.domain.models.network.Network
import com.tangem.domain.models.wallet.UserWalletId
import com.tangem.domain.walletmanager.WalletManagersFacade
import com.tangem.domain.yield.supply.YieldModuleAddressProvider
import com.tangem.utils.coroutines.CoroutineDispatcherProvider
import kotlinx.coroutines.sync.Mutex
import kotlinx.coroutines.sync.withLock
import kotlinx.coroutines.withContext
import java.util.concurrent.ConcurrentHashMap
internal class DefaultYieldModuleAddressProvider(
private val walletManagersFacade: WalletManagersFacade,
private val dispatchers: CoroutineDispatcherProvider,
) : YieldModuleAddressProvider {
private data class Key(val userWalletId: UserWalletId, val networkId: Network.ID)
private val cache = ConcurrentHashMap<Key, String>()
private val mutex = Mutex()
override suspend fun getOrFetch(userWalletId: UserWalletId, network: Network): String? {
val key = Key(userWalletId, network.id)
cache[key]?.let { return it }
return withContext(dispatchers.io) {
mutex.withLock {
cache[key]?.let { return@withLock it }
val walletManager = walletManagersFacade.getOrCreateWalletManager(
userWalletId = userWalletId,
blockchain = network.toBlockchain(),
derivationPath = network.derivationPath.value,
) ?: error("Wallet manager not found for $network")
// SDK returns ZERO_ADDRESS on internal failure (e.g. RPC error). Treat that as
// "unavailable" so callers are forced by the type system to fall back instead
// of using it as a destination.
val address = walletManager.getYieldModuleAddress()
.takeIf { it != EthereumUtils.ZERO_ADDRESS }
if (address != null) cache[key] = address
address
}
}
}
override fun invalidate(userWalletId: UserWalletId?) {
if (userWalletId == null) {
cache.clear()
} else {
cache.keys.removeAll { it.userWalletId == userWalletId }
}
}
}

View file

@ -11,6 +11,7 @@ import com.tangem.blockchain.yieldsupply.YieldSupplyContractCallDataProviderFact
import com.tangem.blockchainsdk.utils.toBlockchain
import com.tangem.domain.models.currency.CryptoCurrency
import com.tangem.domain.models.currency.CryptoCurrencyStatus
import com.tangem.domain.models.network.Network
import com.tangem.domain.models.wallet.UserWalletId
import com.tangem.domain.models.yield.supply.YieldSupplyStatus
import com.tangem.domain.utils.convertToSdkAmount
@ -127,6 +128,11 @@ internal class DefaultYieldSupplyTransactionRepository(
val amount = getEnterAmount(cryptoCurrency, yieldSupplyStatus)
val emptyContractAddress = existingYieldAddress == null || existingYieldAddress == EthereumUtils.ZERO_ADDRESS
val activeYieldContractAddress = if (emptyContractAddress) {
calculatedYieldContractAddress
} else {
existingYieldAddress
}
when {
yieldSupplyStatus == null || emptyContractAddress -> {
@ -143,7 +149,7 @@ internal class DefaultYieldSupplyTransactionRepository(
createInitTokenTransaction(
walletManager = walletManager,
cryptoCurrency = cryptoCurrency,
yieldContractAddress = calculatedYieldContractAddress,
yieldContractAddress = activeYieldContractAddress,
amount = amount,
maxNetworkFee = maxNetworkFee,
),
@ -152,7 +158,7 @@ internal class DefaultYieldSupplyTransactionRepository(
createReactivateTokenTransaction(
walletManager = walletManager,
cryptoCurrency = cryptoCurrency,
yieldContractAddress = calculatedYieldContractAddress,
yieldContractAddress = activeYieldContractAddress,
amount = amount,
maxNetworkFee = maxNetworkFee,
),
@ -166,7 +172,7 @@ internal class DefaultYieldSupplyTransactionRepository(
walletManager = walletManager,
cryptoCurrency = cryptoCurrency,
callData = ApprovalERC20TokenCallData(
spenderAddress = calculatedYieldContractAddress,
spenderAddress = activeYieldContractAddress,
amount = null,
),
destinationAddress = cryptoCurrency.contractAddress,
@ -182,7 +188,7 @@ internal class DefaultYieldSupplyTransactionRepository(
walletManager = walletManager,
cryptoCurrency = cryptoCurrency,
amount = amount,
yieldContractAddress = calculatedYieldContractAddress,
yieldContractAddress = activeYieldContractAddress,
),
)
}
@ -218,6 +224,20 @@ internal class DefaultYieldSupplyTransactionRepository(
}.onFailure { TangemLogger.e("Error", it) }.getOrThrow()
}
override suspend fun wrapYieldSwapCallDataWithUpgradeIfNeeded(
userWalletId: UserWalletId,
network: Network,
callData: SmartContractCallData,
): SmartContractCallData = withContext(dispatchers.io) {
val walletManager = walletManagersFacade.getOrCreateWalletManager(
userWalletId = userWalletId,
blockchain = network.toBlockchain(),
derivationPath = network.derivationPath.value,
) ?: error("Wallet manager not found for $network")
val versionStatus = walletManager.checkModuleVersionStatus()
YieldSupplyContractCallDataProviderFactory.wrapWithUpgradeIfNeeded(versionStatus, callData)
}
private suspend fun getYieldTokenStatus(
walletManager: WalletManager,
cryptoCurrency: CryptoCurrency.Token,

View file

@ -1,6 +1,7 @@
package com.tangem.data.yield.supply.di
import com.tangem.core.analytics.api.AnalyticsExceptionHandler
import com.tangem.data.yield.supply.DefaultYieldModuleAddressProvider
import com.tangem.data.yield.supply.DefaultYieldSupplyRepository
import com.tangem.data.yield.supply.DefaultYieldSupplyErrorResolver
import com.tangem.data.yield.supply.DefaultYieldSupplyTransactionRepository
@ -12,6 +13,7 @@ import com.tangem.datasource.local.yieldsupply.YieldMarketsStore
import com.tangem.datasource.local.yieldsupply.promo.YieldBoostPromoStore
import com.tangem.datasource.local.yieldsupply.promo.YieldBoostStatusStore
import com.tangem.domain.walletmanager.WalletManagersFacade
import com.tangem.domain.yield.supply.YieldModuleAddressProvider
import com.tangem.domain.yield.supply.YieldSupplyRepository
import com.tangem.domain.yield.supply.YieldSupplyErrorResolver
import com.tangem.domain.yield.supply.YieldSupplyTransactionRepository
@ -65,6 +67,18 @@ internal object YieldSupplyDataModule {
return DefaultYieldSupplyErrorResolver
}
@Provides
@Singleton
fun provideYieldModuleAddressProvider(
walletManagersFacade: WalletManagersFacade,
dispatchers: CoroutineDispatcherProvider,
): YieldModuleAddressProvider {
return DefaultYieldModuleAddressProvider(
walletManagersFacade = walletManagersFacade,
dispatchers = dispatchers,
)
}
@Provides
@Singleton
fun provideYieldPromoRepository(

View file

@ -30,4 +30,6 @@ data class SwapCurrencyStatus(
get() = status.currency
val userWalletId: UserWalletId
get() = userWallet.walletId
val isYieldSupplyActive: Boolean
get() = status.value.yieldSupplyStatus?.isActive == true
}

View file

@ -46,7 +46,7 @@ class GetEthSpecificFeeUseCase(
val minimalFee = getEthLegacyFee(
gasPrice = gasPriceResult,
gasLimit = gasLimit,
decimals = cryptoCurrency.decimals,
decimals = blockchain.decimals(),
blockchain = blockchain,
)
@ -54,7 +54,7 @@ class GetEthSpecificFeeUseCase(
val normalFee = getEthLegacyFee(
gasPrice = normalGasPrice,
gasLimit = gasLimit,
decimals = cryptoCurrency.decimals,
decimals = blockchain.decimals(),
blockchain = blockchain,
)
@ -64,7 +64,7 @@ class GetEthSpecificFeeUseCase(
val priorityFee = getEthLegacyFee(
gasPrice = priorityGasPrice,
gasLimit = gasLimit,
decimals = cryptoCurrency.decimals,
decimals = blockchain.decimals(),
blockchain = blockchain,
)

View file

@ -0,0 +1,25 @@
package com.tangem.domain.yield.supply
import com.tangem.domain.models.network.Network
import com.tangem.domain.models.wallet.UserWalletId
/**
* Resolves the yield-module proxy address for a `(wallet, network)` pair and caches the result.
*
* The address is derived from on-chain state (factory contract + user's wallet) and is stable
* for the lifetime of the wallet, so caching the result avoids redundant blockchain calls.
*
* Call [invalidate] when the wallet's yield-module state may have changed (e.g. after a
* successful upgrade or removal of yield-supply).
*/
interface YieldModuleAddressProvider {
/**
* Returns the yield-module proxy address, or `null` if the address is currently unavailable
* (e.g. RPC failure inside the SDK).
*/
suspend fun getOrFetch(userWalletId: UserWalletId, network: Network): String?
/** Drops cached entries for [userWalletId], or the entire cache when [userWalletId] is `null`. */
fun invalidate(userWalletId: UserWalletId? = null)
}

View file

@ -1,9 +1,11 @@
package com.tangem.domain.yield.supply
import com.tangem.blockchain.common.TransactionData
import com.tangem.blockchain.common.smartcontract.SmartContractCallData
import com.tangem.blockchain.common.transaction.Fee
import com.tangem.domain.models.currency.CryptoCurrency
import com.tangem.domain.models.currency.CryptoCurrencyStatus
import com.tangem.domain.models.network.Network
import com.tangem.domain.models.wallet.UserWalletId
import java.math.BigDecimal
@ -24,4 +26,14 @@ interface YieldSupplyTransactionRepository {
suspend fun getYieldContractAddress(userWalletId: UserWalletId, cryptoCurrency: CryptoCurrency): String?
suspend fun getEffectiveProtocolBalance(userWalletId: UserWalletId, cryptoCurrency: CryptoCurrency): BigDecimal?
/**
* Checks the version status of the user's yield-module contract and wraps [callData] with an
* upgrade transaction if the deployed version is out of date.
*/
suspend fun wrapYieldSwapCallDataWithUpgradeIfNeeded(
userWalletId: UserWalletId,
network: Network,
callData: SmartContractCallData,
): SmartContractCallData
}

View file

@ -0,0 +1,25 @@
package com.tangem.domain.yield.supply.usecase
import com.tangem.blockchain.common.smartcontract.SmartContractCallData
import com.tangem.domain.models.network.Network
import com.tangem.domain.models.wallet.UserWalletId
import com.tangem.domain.yield.supply.YieldSupplyTransactionRepository
/**
* Wraps a yield-swap call data with a yield-module upgrade transaction when the user's deployed
* yield-module contract version is out of date.
*/
class WrapYieldSwapCallDataWithUpgradeUseCase(
private val yieldSupplyTransactionRepository: YieldSupplyTransactionRepository,
) {
suspend operator fun invoke(
userWalletId: UserWalletId,
network: Network,
callData: SmartContractCallData,
): SmartContractCallData = yieldSupplyTransactionRepository.wrapYieldSwapCallDataWithUpgradeIfNeeded(
userWalletId = userWalletId,
network = network,
callData = callData,
)
}

View file

@ -0,0 +1,40 @@
package com.tangem.features.approval.api
import com.tangem.core.decompose.context.AppComponentContext
import com.tangem.core.ui.decompose.ComposableBottomSheetComponent
/**
* Entry component that wraps the two approval-flow variants:
*
* - [Mode.FullApproval] original [GiveApprovalComponent] which renders the approval-type
* selector together with the fee selector and submits the approval transaction.
* - [Mode.SelectOnly] [SelectApprovalTypeComponent] which only collects the approval-type
* choice and returns it to the caller via its own [SelectApprovalTypeComponent.Callback].
*
* Callers depend only on this single factory and pass the appropriate [Mode]; the entry
* component internally creates the corresponding child and delegates the bottom sheet
* rendering and dismissal to it.
*/
interface GiveApprovalEntryComponent : ComposableBottomSheetComponent {
data class Params(
val mode: Mode,
)
sealed interface Mode {
/** Full flow: approval-type selector + fee selector + transaction submission. */
data class FullApproval(
val params: GiveApprovalComponent.Params,
) : Mode
/** Selection-only flow: returns the chosen approval type without sending anything. */
data class SelectOnly(
val params: SelectApprovalTypeComponent.Params,
) : Mode
}
interface Factory {
fun create(context: AppComponentContext, params: Params): GiveApprovalEntryComponent
}
}

View file

@ -0,0 +1,39 @@
package com.tangem.features.approval.api
import com.tangem.common.ui.bottomsheet.permission.state.ApproveType
import com.tangem.core.decompose.context.AppComponentContext
import com.tangem.core.ui.decompose.ComposableBottomSheetComponent
import com.tangem.core.ui.extensions.TextReference
import com.tangem.domain.models.currency.CryptoCurrencyStatus
import com.tangem.domain.models.wallet.UserWalletId
/**
* Selection-only variant of [GiveApprovalComponent].
*
* Shows the same approval-type selector UI (LIMITED vs UNLIMITED) but does NOT submit the
* approval transaction. Instead, the chosen [ApproveType] is returned to the caller via
* [Callback.onApproveTypeSelected] when the user confirms. The caller is responsible for any
* downstream action (e.g. building the transaction, sending it, navigation).
*
* Intended for flows where the approval-type choice has to be collected separately from the
* actual fee selection / transaction submission step.
*/
interface SelectApprovalTypeComponent : ComposableBottomSheetComponent {
data class Params(
val userWalletId: UserWalletId,
val cryptoCurrencyStatus: CryptoCurrencyStatus,
val amountFooter: TextReference,
val initialApproveType: ApproveType = ApproveType.LIMITED,
val callback: Callback,
)
interface Callback {
fun onApproveTypeSelected(approveType: ApproveType)
fun onCancelClick()
}
interface Factory {
fun create(context: AppComponentContext, params: Params): SelectApprovalTypeComponent
}
}

View file

@ -0,0 +1,58 @@
package com.tangem.features.approval.impl
import androidx.compose.runtime.Composable
import com.tangem.core.decompose.context.AppComponentContext
import com.tangem.core.decompose.context.child
import com.tangem.core.ui.decompose.ComposableBottomSheetComponent
import com.tangem.features.approval.api.GiveApprovalComponent
import com.tangem.features.approval.api.GiveApprovalEntryComponent
import com.tangem.features.approval.api.SelectApprovalTypeComponent
import dagger.assisted.Assisted
import dagger.assisted.AssistedFactory
import dagger.assisted.AssistedInject
/**
* Default implementation of [GiveApprovalEntryComponent].
*
* Picks the concrete child component (full [GiveApprovalComponent] or selection-only
* [SelectApprovalTypeComponent]) at construction time based on
* [GiveApprovalEntryComponent.Params.mode] and delegates [BottomSheet] and [dismiss] to it.
*
* Callers only need to depend on [GiveApprovalEntryComponent.Factory] regardless of the
* underlying mode.
*/
internal class DefaultGiveApprovalEntryComponent @AssistedInject constructor(
@Assisted appComponentContext: AppComponentContext,
@Assisted private val params: GiveApprovalEntryComponent.Params,
giveApprovalComponentFactory: GiveApprovalComponent.Factory,
selectApprovalTypeComponentFactory: SelectApprovalTypeComponent.Factory,
) : GiveApprovalEntryComponent, AppComponentContext by appComponentContext {
private val delegate: ComposableBottomSheetComponent = when (val mode = params.mode) {
is GiveApprovalEntryComponent.Mode.FullApproval -> giveApprovalComponentFactory.create(
context = child("giveApprovalEntry_full"),
params = mode.params,
)
is GiveApprovalEntryComponent.Mode.SelectOnly -> selectApprovalTypeComponentFactory.create(
context = child("giveApprovalEntry_select"),
params = mode.params,
)
}
override fun dismiss() {
delegate.dismiss()
}
@Composable
override fun BottomSheet() {
delegate.BottomSheet()
}
@AssistedFactory
interface Factory : GiveApprovalEntryComponent.Factory {
override fun create(
context: AppComponentContext,
params: GiveApprovalEntryComponent.Params,
): DefaultGiveApprovalEntryComponent
}
}

View file

@ -0,0 +1,80 @@
package com.tangem.features.approval.impl
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.remember
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import com.tangem.core.decompose.context.AppComponentContext
import com.tangem.core.decompose.model.getOrCreateModel
import com.tangem.core.ui.R
import com.tangem.core.ui.components.appbar.models.TopAppBarButtonUM
import com.tangem.core.ui.components.bottomsheets.TangemBottomSheetConfig
import com.tangem.core.ui.components.bottomsheets.TangemBottomSheetConfigContent
import com.tangem.core.ui.components.bottomsheets.sheet.TangemBottomSheet
import com.tangem.core.ui.extensions.resourceReference
import com.tangem.core.ui.res.TangemTheme
import com.tangem.features.approval.api.SelectApprovalTypeComponent
import com.tangem.features.approval.impl.model.SelectApprovalTypeModel
import com.tangem.features.approval.impl.ui.SelectApprovalTypeContent
import dagger.assisted.Assisted
import dagger.assisted.AssistedFactory
import dagger.assisted.AssistedInject
/**
* Default implementation of [SelectApprovalTypeComponent].
*
* Renders the same selection UI as the full [com.tangem.features.approval.api.GiveApprovalComponent]
* but without the fee selector block and without dispatching the on-chain approval transaction.
* Dismissing the bottom sheet (close button or external dismiss) is treated as a cancel.
*/
internal class DefaultSelectApprovalTypeComponent @AssistedInject constructor(
@Assisted appComponentContext: AppComponentContext,
@Assisted private val params: SelectApprovalTypeComponent.Params,
) : SelectApprovalTypeComponent, AppComponentContext by appComponentContext {
private val model: SelectApprovalTypeModel = getOrCreateModel(params = params)
private val currency: String = params.cryptoCurrencyStatus.currency.symbol
override fun dismiss() {
params.callback.onCancelClick()
}
@Composable
override fun BottomSheet() {
val uiState by model.uiState.collectAsStateWithLifecycle()
val config = remember {
TangemBottomSheetConfig(
isShown = true,
onDismissRequest = ::dismiss,
content = TangemBottomSheetConfigContent.Empty,
)
}
TangemBottomSheet<TangemBottomSheetConfigContent.Empty>(
config = config,
containerColor = TangemTheme.colors.background.tertiary,
titleText = resourceReference(R.string.give_permission_title),
titleAction = TopAppBarButtonUM.Icon(
iconRes = R.drawable.ic_close_new_20,
onClicked = model::onCancelClick,
),
) {
SelectApprovalTypeContent(
currency = currency,
uiState = uiState,
onChangeApproveType = model::onChangeApproveType,
onConfirmClick = model::onConfirmClick,
)
}
}
@AssistedFactory
interface Factory : SelectApprovalTypeComponent.Factory {
override fun create(
context: AppComponentContext,
params: SelectApprovalTypeComponent.Params,
): DefaultSelectApprovalTypeComponent
}
}

View file

@ -3,10 +3,15 @@ package com.tangem.features.approval.impl.di
import com.tangem.core.decompose.di.ModelComponent
import com.tangem.core.decompose.model.Model
import com.tangem.features.approval.api.GiveApprovalComponent
import com.tangem.features.approval.api.GiveApprovalEntryComponent
import com.tangem.features.approval.api.GiveApprovalFeatureToggles
import com.tangem.features.approval.api.SelectApprovalTypeComponent
import com.tangem.features.approval.impl.DefaultGiveApprovalComponent
import com.tangem.features.approval.impl.DefaultGiveApprovalEntryComponent
import com.tangem.features.approval.impl.DefaultGiveApprovalFeatureToggles
import com.tangem.features.approval.impl.DefaultSelectApprovalTypeComponent
import com.tangem.features.approval.impl.model.GiveApprovalModel
import com.tangem.features.approval.impl.model.SelectApprovalTypeModel
import dagger.Binds
import dagger.Module
import dagger.hilt.InstallIn
@ -26,6 +31,18 @@ internal interface GiveApprovalFeatureModule {
@Binds
@Singleton
fun bindComponentFactory(factory: DefaultGiveApprovalComponent.Factory): GiveApprovalComponent.Factory
@Binds
@Singleton
fun bindSelectApprovalTypeComponentFactory(
factory: DefaultSelectApprovalTypeComponent.Factory,
): SelectApprovalTypeComponent.Factory
@Binds
@Singleton
fun bindGiveApprovalEntryComponentFactory(
factory: DefaultGiveApprovalEntryComponent.Factory,
): GiveApprovalEntryComponent.Factory
}
@Module
@ -36,4 +53,9 @@ internal interface GiveApprovalModelModule {
@IntoMap
@ClassKey(GiveApprovalModel::class)
fun bindModel(model: GiveApprovalModel): Model
@Binds
@IntoMap
@ClassKey(SelectApprovalTypeModel::class)
fun bindSelectApprovalTypeModel(model: SelectApprovalTypeModel): Model
}

View file

@ -0,0 +1,52 @@
package com.tangem.features.approval.impl.model
import androidx.compose.runtime.Stable
import com.tangem.common.ui.bottomsheet.permission.state.ApproveType
import com.tangem.core.decompose.di.ModelScoped
import com.tangem.core.decompose.model.Model
import com.tangem.core.decompose.model.ParamsContainer
import com.tangem.features.approval.api.SelectApprovalTypeComponent
import com.tangem.utils.coroutines.CoroutineDispatcherProvider
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.update
import javax.inject.Inject
/**
* Model for [SelectApprovalTypeComponent].
*
* Keeps the currently selected [ApproveType] and exposes intents to change it, open the
* learn-more URL, confirm the selection, and cancel. Unlike [GiveApprovalModel] this model
* does NOT load fees or submit any transaction confirmation simply notifies the caller
* via the params callback with the selected [ApproveType].
*/
@Stable
@ModelScoped
internal class SelectApprovalTypeModel @Inject constructor(
override val dispatchers: CoroutineDispatcherProvider,
paramsContainer: ParamsContainer,
) : Model() {
private val params: SelectApprovalTypeComponent.Params = paramsContainer.require()
val uiState: StateFlow<SelectApprovalTypeUM>
field = MutableStateFlow(
SelectApprovalTypeUM(
approveType = params.initialApproveType,
subtitle = params.amountFooter,
),
)
fun onChangeApproveType(approveType: ApproveType) {
if (uiState.value.approveType == approveType) return
uiState.update { it.copy(approveType = approveType) }
}
fun onConfirmClick() {
params.callback.onApproveTypeSelected(uiState.value.approveType)
}
fun onCancelClick() {
params.callback.onCancelClick()
}
}

View file

@ -0,0 +1,12 @@
package com.tangem.features.approval.impl.model
import com.tangem.common.ui.bottomsheet.permission.state.ApproveType
import com.tangem.core.ui.extensions.TextReference
import kotlinx.collections.immutable.ImmutableList
import kotlinx.collections.immutable.toImmutableList
internal data class SelectApprovalTypeUM(
val subtitle: TextReference,
val approveType: ApproveType,
val approveItems: ImmutableList<ApproveType> = ApproveType.entries.toImmutableList(),
)

View file

@ -0,0 +1,183 @@
package com.tangem.features.approval.impl.ui
import androidx.compose.foundation.background
import androidx.compose.foundation.clickable
import androidx.compose.foundation.interaction.MutableInteractionSource
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.wrapContentSize
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material3.DropdownMenu
import androidx.compose.material3.DropdownMenuItem
import androidx.compose.material3.Icon
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.ripple
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.vector.ImageVector
import androidx.compose.ui.graphics.vector.rememberVectorPainter
import androidx.compose.ui.layout.onSizeChanged
import androidx.compose.ui.platform.LocalDensity
import androidx.compose.ui.res.vectorResource
import androidx.compose.ui.unit.DpOffset
import androidx.compose.ui.unit.IntSize
import androidx.compose.ui.unit.dp
import androidx.compose.ui.window.PopupProperties
import androidx.compose.material3.Text as M3Text
import com.tangem.common.ui.bottomsheet.permission.state.ApproveType
import com.tangem.core.ui.R
import com.tangem.core.ui.components.SpacerWMax
import com.tangem.core.ui.extensions.resolveReference
import com.tangem.core.ui.extensions.stringResourceSafe
import com.tangem.core.ui.res.TangemTheme
import kotlinx.collections.immutable.ImmutableList
/**
* Reusable row that shows "Amount for {currency}" on the left and the currently selected
* [ApproveType] on the right, with a dropdown to switch between the available types.
*
* Used by both [GiveApprovalContent] (full approval flow) and [SelectApprovalTypeContent]
* (selection-only flow).
*/
@Composable
internal fun ApprovalTypeSelectorRow(
currency: String,
approveType: ApproveType,
approveItems: ImmutableList<ApproveType>,
onChangeApproveType: (ApproveType) -> Unit,
modifier: Modifier = Modifier,
) {
var isExpandSelector by remember { mutableStateOf(false) }
var amountSize by remember { mutableStateOf(IntSize.Zero) }
Box(
modifier = modifier
.fillMaxWidth()
.clip(TangemTheme.shapes.roundedCornersXMedium)
.background(TangemTheme.colors.background.action)
.clickable(
interactionSource = remember { MutableInteractionSource() },
indication = ripple(),
onClick = { isExpandSelector = true },
),
) {
Row(
modifier = Modifier
.fillMaxWidth()
.onSizeChanged { amountSize = it }
.padding(vertical = 12.dp, horizontal = 14.dp),
horizontalArrangement = Arrangement.SpaceBetween,
verticalAlignment = Alignment.CenterVertically,
) {
M3Text(
text = stringResourceSafe(id = R.string.give_permission_rows_amount, currency),
color = TangemTheme.colors.text.primary1,
style = TangemTheme.typography.subtitle1,
maxLines = 1,
)
SpacerWMax()
M3Text(
text = approveType.text.resolveReference(),
color = TangemTheme.colors.text.tertiary,
style = TangemTheme.typography.body1,
maxLines = 1,
)
Icon(
painter = rememberVectorPainter(ImageVector.vectorResource(id = R.drawable.ic_chevron_24)),
contentDescription = null,
tint = TangemTheme.colors.icon.informative,
modifier = Modifier.padding(start = TangemTheme.dimens.spacing2),
)
}
ApprovalTypeDropdown(
isExpanded = isExpandSelector,
onDismiss = { isExpandSelector = false },
onItemClick = { type ->
isExpandSelector = false
onChangeApproveType(type)
},
items = approveItems,
selectedType = approveType,
amountSize = amountSize,
)
}
}
@Suppress("LongParameterList")
@Composable
private fun ApprovalTypeDropdown(
isExpanded: Boolean,
onDismiss: () -> Unit,
onItemClick: (ApproveType) -> Unit,
items: ImmutableList<ApproveType>,
selectedType: ApproveType,
amountSize: IntSize,
) {
var dropDownWidth by remember { mutableStateOf(IntSize.Zero) }
val offsetY = amountSize.height.times(-1)
val offsetX = amountSize.width - dropDownWidth.width
MaterialTheme(
colorScheme = MaterialTheme.colorScheme.copy(surface = TangemTheme.colors.background.action),
shapes = MaterialTheme.shapes.copy(extraSmall = RoundedCornerShape(TangemTheme.dimens.radius16)),
) {
DropdownMenu(
expanded = isExpanded,
onDismissRequest = onDismiss,
properties = PopupProperties(clippingEnabled = false),
offset = with(LocalDensity.current) {
DpOffset(x = offsetX.toDp(), y = offsetY.toDp())
},
modifier = Modifier
.wrapContentSize()
.background(TangemTheme.colors.background.action)
.onSizeChanged { dropDownWidth = it },
) {
items.forEach { item ->
val color = if (item == selectedType) TangemTheme.colors.icon.accent else Color.Transparent
DropdownMenuItem(
modifier = Modifier.fillMaxWidth(),
text = {
Row {
M3Text(
text = when (item) {
ApproveType.LIMITED -> stringResourceSafe(
id = R.string.give_permission_current_transaction,
)
ApproveType.UNLIMITED -> stringResourceSafe(
id = R.string.give_permission_unlimited,
)
},
color = TangemTheme.colors.text.primary1,
style = TangemTheme.typography.body1,
maxLines = 1,
)
SpacerWMax()
Icon(
painter = rememberVectorPainter(
image = ImageVector.vectorResource(id = R.drawable.ic_check_24),
),
tint = color,
contentDescription = null,
modifier = Modifier.padding(start = TangemTheme.dimens.size20),
)
}
},
onClick = {
onItemClick.invoke(item)
},
)
}
}
}
}

View file

@ -0,0 +1,157 @@
package com.tangem.features.approval.impl.ui
import android.content.res.Configuration
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
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.tooling.preview.Preview
import androidx.compose.ui.tooling.preview.PreviewParameter
import androidx.compose.ui.tooling.preview.PreviewParameterProvider
import androidx.compose.ui.unit.dp
import com.tangem.common.ui.bottomsheet.permission.state.ApproveType
import com.tangem.core.ui.R
import com.tangem.core.ui.components.PrimaryButton
import com.tangem.core.ui.components.SpacerH
import com.tangem.core.ui.components.SpacerH16
import com.tangem.core.ui.components.SpacerH18
import com.tangem.core.ui.extensions.*
import com.tangem.core.ui.res.TangemTheme
import com.tangem.core.ui.res.TangemThemePreview
import com.tangem.features.approval.impl.model.SelectApprovalTypeUM
import kotlinx.collections.immutable.persistentListOf
/**
* UI for the selection-only approval variant. Reuses [ApprovalTypeSelectorRow] for the
* approval-type picker. The primary button calls [onConfirmClick] which is wired to a
* callback that returns the chosen [ApproveType]
* to the caller (instead of submitting an on-chain transaction).
*/
@Composable
@Suppress("LongParameterList")
internal fun SelectApprovalTypeContent(
currency: String,
uiState: SelectApprovalTypeUM,
onChangeApproveType: (ApproveType) -> Unit,
onConfirmClick: () -> Unit,
modifier: Modifier = Modifier,
) {
Column(
modifier = modifier.fillMaxWidth(),
horizontalAlignment = Alignment.CenterHorizontally,
) {
Text(
text = uiState.subtitle.resolveAnnotatedReference(),
color = TangemTheme.colors.text.secondary,
style = TangemTheme.typography.body2,
textAlign = TextAlign.Center,
modifier = Modifier.padding(
top = 2.dp,
start = 16.dp,
end = 16.dp,
),
)
SpacerH18()
ApprovalTypeSelectorRow(
currency = currency,
approveType = uiState.approveType,
approveItems = uiState.approveItems,
onChangeApproveType = onChangeApproveType,
modifier = Modifier.padding(horizontal = TangemTheme.dimens.spacing16),
)
SpacerH(height = TangemTheme.dimens.spacing20)
PrimaryButton(
text = stringResourceSafe(id = R.string.common_continue),
onClick = onConfirmClick,
enabled = true,
modifier = Modifier
.fillMaxWidth()
.padding(horizontal = TangemTheme.dimens.spacing16),
)
SpacerH16()
}
}
// region Preview
@Composable
@Preview(showBackground = true, widthDp = 360)
@Preview(showBackground = true, widthDp = 360, uiMode = Configuration.UI_MODE_NIGHT_YES)
private fun SelectApprovalTypeContentPreview(
@PreviewParameter(SelectApprovalTypeContentPreviewProvider::class) params: SelectApprovalTypePreviewParams,
) {
TangemThemePreview {
SelectApprovalTypeContent(
currency = params.currency,
uiState = params.uiState,
onChangeApproveType = {},
onConfirmClick = {},
)
}
}
private data class SelectApprovalTypePreviewParams(
val currency: String,
val uiState: SelectApprovalTypeUM,
)
private class SelectApprovalTypeContentPreviewProvider : PreviewParameterProvider<SelectApprovalTypePreviewParams> {
override val values: Sequence<SelectApprovalTypePreviewParams>
get() = sequenceOf(
SelectApprovalTypePreviewParams(
currency = "USDT",
uiState = SelectApprovalTypeUM(
subtitle = combinedReference(
resourceReference(
id = R.string.give_permission_swap_subtitle_v2,
// Arg is only used in iOS
formatArgs = wrappedList(""),
),
styledResourceReference(
id = R.string.common_learn_more,
spanStyleReference = {
TangemTheme.typography.caption2
.copy(color = TangemTheme.colors.text.accent)
.toSpanStyle()
},
onClick = { },
),
),
approveType = ApproveType.LIMITED,
approveItems = persistentListOf(ApproveType.LIMITED, ApproveType.UNLIMITED),
),
),
SelectApprovalTypePreviewParams(
currency = "USDC",
uiState = SelectApprovalTypeUM(
subtitle = combinedReference(
resourceReference(
id = com.tangem.common.ui.R.string.give_permission_swap_subtitle_v2,
// Arg is only used in iOS
formatArgs = wrappedList(""),
),
styledResourceReference(
id = com.tangem.common.ui.R.string.common_learn_more,
spanStyleReference = {
TangemTheme.typography.caption2
.copy(color = TangemTheme.colors.text.accent)
.toSpanStyle()
},
onClick = {},
),
),
approveType = ApproveType.UNLIMITED,
approveItems = persistentListOf(ApproveType.LIMITED, ApproveType.UNLIMITED),
),
),
)
}
// endregion

View file

@ -17,6 +17,8 @@ import androidx.compose.ui.draw.drawBehind
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.layout.onSizeChanged
import androidx.compose.ui.platform.LocalDensity
import androidx.compose.ui.platform.testTag
import com.tangem.core.ui.test.MarketsTestTags
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.tooling.preview.PreviewParameter
import androidx.compose.ui.tooling.preview.PreviewParameterProvider
@ -116,6 +118,7 @@ private fun Content(
LazyColumn(
state = lazyListState,
contentPadding = PaddingValues(bottom = bottomBarHeight, top = contentPadding.calculateTopPadding()),
modifier = Modifier.testTag(MarketsTestTags.TOKEN_DETAILS_CONTENT),
) {
item("header") {
Header(state = state)

View file

@ -18,6 +18,7 @@ import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.vector.ImageVector
import androidx.compose.ui.layout.layoutId
import androidx.compose.ui.platform.LocalDensity
import androidx.compose.ui.platform.testTag
import androidx.compose.ui.res.vectorResource
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.text.style.TextOverflow
@ -48,6 +49,7 @@ import com.tangem.core.ui.ds.topbar.TangemTopBar
import com.tangem.core.ui.ds.topbar.TangemTopBarType
import com.tangem.core.ui.extensions.*
import com.tangem.core.ui.res.LocalRedesignEnabled
import com.tangem.core.ui.test.TokenElementsTestTags
import com.tangem.core.ui.res.TangemTheme
import com.tangem.core.ui.res.TangemThemePreview
import com.tangem.core.ui.res.TangemThemePreviewRedesign
@ -314,13 +316,15 @@ private fun ExchangeItemRowContent(exchangeItemUM: ExchangeItemUM.Content, modif
tangemIconUM = exchangeItemUM.icon,
modifier = Modifier
.layoutId(layoutId = TangemRowLayoutId.HEAD)
.size(TangemTheme.dimens2.x10),
.size(TangemTheme.dimens2.x10)
.testTag(TokenElementsTestTags.TOKEN_ICON),
)
Text(
modifier = Modifier
.padding(start = TangemTheme.dimens2.x2)
.layoutId(TangemRowLayoutId.START_TOP),
.layoutId(TangemRowLayoutId.START_TOP)
.testTag(TokenElementsTestTags.TOKEN_TITLE),
text = exchangeItemUM.title.resolveReference(),
style = TangemTheme.typography2.bodySemibold16,
color = TangemTheme.colors2.text.neutral.primary,
@ -329,7 +333,8 @@ private fun ExchangeItemRowContent(exchangeItemUM: ExchangeItemUM.Content, modif
Text(
modifier = Modifier
.padding(start = TangemTheme.dimens2.x2)
.layoutId(TangemRowLayoutId.START_BOTTOM),
.layoutId(TangemRowLayoutId.START_BOTTOM)
.testTag(TokenElementsTestTags.TOKEN_PRICE),
text = exchangeItemUM.subTitle.resolveReference(),
style = TangemTheme.typography2.captionSemibold12,
color = TangemTheme.colors2.text.neutral.secondary,
@ -338,7 +343,8 @@ private fun ExchangeItemRowContent(exchangeItemUM: ExchangeItemUM.Content, modif
Text(
modifier = Modifier
.padding(start = TangemTheme.dimens2.x2)
.layoutId(TangemRowLayoutId.END_TOP),
.layoutId(TangemRowLayoutId.END_TOP)
.testTag(TokenElementsTestTags.TOKEN_FIAT_AMOUNT_TEXT),
text = exchangeItemUM.volumeInUsd.resolveReference(),
style = TangemTheme.typography2.bodySemibold16,
color = TangemTheme.colors2.text.neutral.primary,
@ -351,7 +357,8 @@ private fun ExchangeItemRowContent(exchangeItemUM: ExchangeItemUM.Content, modif
shape = CircleShape,
)
.padding(vertical = 2.dp, horizontal = 6.dp)
.layoutId(TangemRowLayoutId.END_BOTTOM),
.layoutId(TangemRowLayoutId.END_BOTTOM)
.testTag(TokenElementsTestTags.TOKEN_CRYPTO_AMOUNT),
text = exchangeItemUM.auditLabel.text.resolveReference(),
style = TangemTheme.typography2.captionSemibold11,
color = getColorByTrustValue(exchangeItemUM.auditLabel.type),

View file

@ -10,10 +10,9 @@ import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.graphics.vector.ImageVector
import androidx.compose.ui.platform.testTag
import androidx.compose.ui.res.painterResource
import androidx.compose.ui.res.vectorResource
import androidx.compose.ui.semantics.semantics
import androidx.compose.ui.semantics.testTag
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.tooling.preview.PreviewParameter
@ -65,7 +64,8 @@ private fun ListedOnBlockV1(state: ListedOnUM, modifier: Modifier = Modifier) {
.clip(shape = TangemTheme.shapes.roundedCornersXMedium)
.clickable(enabled = state is ListedOnUM.Content) {
(state as? ListedOnUM.Content)?.onClick?.invoke()
},
}
.testTag(MarketsTestTags.LISTED_ON_BLOCK),
) {
Description(
state = state,
@ -89,9 +89,11 @@ private fun ListedOnBlockV1(state: ListedOnUM, modifier: Modifier = Modifier) {
@Composable
private fun ListedOnBlockV2(state: ListedOnUM, modifier: Modifier = Modifier) {
TokenMarketInformationBlock(
modifier = modifier.clickable(enabled = state is ListedOnUM.Content) {
(state as? ListedOnUM.Content)?.onClick?.invoke()
},
modifier = modifier
.clickable(enabled = state is ListedOnUM.Content) {
(state as? ListedOnUM.Content)?.onClick?.invoke()
}
.testTag(MarketsTestTags.LISTED_ON_BLOCK),
title = {
Row(verticalAlignment = Alignment.CenterVertically) {
Column(verticalArrangement = Arrangement.spacedBy(TangemTheme.dimens2.x1)) {
@ -103,6 +105,7 @@ private fun ListedOnBlockV2(state: ListedOnUM, modifier: Modifier = Modifier) {
overflow = TextOverflow.Ellipsis,
)
Text(
modifier = Modifier.testTag(MarketsTestTags.LISTED_ON_EXCHANGES_COUNT),
text = state.description.resolveReference(),
style = TangemTheme.typography2.headingSemibold20,
color = TangemTheme.colors2.text.neutral.primary,
@ -178,7 +181,7 @@ internal fun ListedOnBlockPlaceholderV2(modifier: Modifier = Modifier) {
private fun Description(state: ListedOnUM, modifier: Modifier = Modifier) {
Text(
text = state.description.resolveReference(),
modifier = modifier.semantics { testTag = MarketsTestTags.LISTED_ON_EXCHANGES_COUNT },
modifier = modifier.testTag(MarketsTestTags.LISTED_ON_EXCHANGES_COUNT),
color = TangemTheme.colors.text.tertiary,
overflow = TextOverflow.Ellipsis,
maxLines = 1,

View file

@ -1,6 +1,7 @@
package com.tangem.features.swap
interface SwapFeatureToggles {
val isYieldSwapEnabled: Boolean
val isSwapSwitchToTransferEnabled: Boolean
val isSwapIntegratedApproveEnabled: Boolean
val isSwapAbEnabled: Boolean

View file

@ -51,8 +51,10 @@ dependencies {
implementation(projects.domain.visa)
implementation(projects.domain.visa.models)
implementation(projects.domain.balanceHiding)
implementation(projects.domain.yieldSupply)
/** Core modules */
implementation(projects.core.configToggles)
implementation(projects.core.utils)
implementation(projects.core.ui)
implementation(projects.core.datasource)

View file

@ -51,6 +51,7 @@ import com.tangem.domain.transaction.usecase.*
import com.tangem.domain.transaction.usecase.gasless.CreateAndSendGaslessTransactionUseCase
import com.tangem.domain.utils.convertToSdkAmount
import com.tangem.domain.walletmanager.WalletManagersFacade
import com.tangem.domain.yield.supply.YieldModuleAddressProvider
import com.tangem.feature.swap.domain.api.SwapRepository
import com.tangem.feature.swap.domain.fee.CexSwapFeeCalculator
import com.tangem.feature.swap.domain.fee.DexSwapFeeCalculator
@ -61,6 +62,7 @@ import com.tangem.feature.swap.domain.models.SwapAmount
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.utils.coroutines.runSuspendCatching
import com.tangem.utils.extensions.orZero
import com.tangem.utils.logging.TangemLogger
@ -99,12 +101,17 @@ internal class SwapInteractorImpl @Inject constructor(
private val getSwapPairUseCase: GetSwapPairUseCase,
private val dexSwapFeeCalculator: DexSwapFeeCalculator,
private val cexSwapFeeCalculator: CexSwapFeeCalculator,
private val swapFeatureToggles: SwapFeatureToggles,
private val yieldModuleAddressProvider: YieldModuleAddressProvider,
) : SwapInteractor {
private val getSelectedAppCurrencyUseCase by lazy(LazyThreadSafetyMode.NONE) {
GetSelectedAppCurrencyUseCase(appCurrencyRepository)
}
private val SwapCurrencyStatus.isYieldSwapActive: Boolean
get() = swapFeatureToggles.isYieldSwapEnabled && isYieldSupplyActive
override suspend fun getPair(
fromSwapCurrencyStatus: SwapCurrencyStatus,
toSwapCurrencyStatus: SwapCurrencyStatus,
@ -254,7 +261,9 @@ internal class SwapInteractorImpl @Inject constructor(
reduceBalanceBy: BigDecimal,
expressOperationType: ExpressOperationType,
): Pair<SwapProvider, SwapState> {
if (fromSwapCurrencyStatus.status.value.yieldSupplyStatus?.isActive == true) {
if (fromSwapCurrencyStatus.status.value.yieldSupplyStatus?.isActive == true &&
!swapFeatureToggles.isYieldSwapEnabled
) {
return provider to produceDexSwapDataError(
error = ExpressDataError.DexActiveSupplyError(),
fromSwapCurrencyStatus = fromSwapCurrencyStatus,
@ -286,19 +295,26 @@ internal class SwapInteractorImpl @Inject constructor(
}
val fromTokenAddress = getTokenAddress(fromSwapCurrencyStatus.currency)
val isAllowedToSpend = maybeQuotes.fold(
ifRight = { quotes ->
quotes.allowanceContract?.let { allowanceContract ->
getAllowanceInfoUseCase(
userWalletId = fromSwapCurrencyStatus.userWalletId,
cryptoCurrency = fromSwapCurrencyStatus.currency,
spenderAddress = allowanceContract,
requiredAmount = amount.value,
).getOrNull() is AllowanceInfo.Enough
} != false
},
ifLeft = { false },
)
val isYieldSwap = fromSwapCurrencyStatus.isYieldSwapActive &&
fromSwapCurrencyStatus.currency is CryptoCurrency.Token
val isAllowedToSpend = if (isYieldSwap) {
maybeQuotes.isRight() &&
fromSwapCurrencyStatus.status.value.yieldSupplyStatus?.isAllowedToSpend == true
} else {
maybeQuotes.fold(
ifRight = { quotes ->
quotes.allowanceContract?.let { allowanceContract ->
getAllowanceInfoUseCase(
userWalletId = fromSwapCurrencyStatus.userWalletId,
cryptoCurrency = fromSwapCurrencyStatus.currency,
spenderAddress = allowanceContract,
requiredAmount = amount.value,
).getOrNull() is AllowanceInfo.Enough
} != false
},
ifLeft = { false },
)
}
if (isAllowedToSpend && allowPermissionsHandler.isAddressAllowanceInProgress(fromTokenAddress)) {
allowPermissionsHandler.removeAddressFromProgress(fromTokenAddress)
@ -308,6 +324,7 @@ internal class SwapInteractorImpl @Inject constructor(
)
}
val isBalanceWithoutFeeEnough = isBalanceEnough(fromSwapCurrencyStatus, amount, null)
val quoteAllowanceContract = maybeQuotes.getOrNull()?.allowanceContract
return if (isAllowedToSpend && isBalanceWithoutFeeEnough) {
provider to loadDexSwapDataNoFee(
provider = provider,
@ -315,6 +332,7 @@ internal class SwapInteractorImpl @Inject constructor(
toSwapCurrencyStatus = toSwapCurrencyStatus,
amount = amount,
expressOperationType = expressOperationType,
quoteAllowanceContract = quoteAllowanceContract,
)
} else {
val quoteBalanceStatus = if (isBalanceWithoutFeeEnough) {
@ -377,6 +395,7 @@ internal class SwapInteractorImpl @Inject constructor(
toSwapCurrencyStatus = toSwapCurrencyStatus,
amount = amount,
expressOperationType = expressOperationType,
quoteAllowanceContract = maybeQuotes.getOrNull()?.allowanceContract,
)
} else {
provider to getQuotesState(
@ -635,28 +654,54 @@ internal class SwapInteractorImpl @Inject constructor(
swapFee: SwapFee,
): SwapTransactionState {
val amountDecimal = requireNotNull(toBigDecimalOrNull(amountToSwap)) { "wrong amount format" }
val txValue = requireNotNull(swapData.transaction.txValue) { "txValue is null" }
val amount = SwapAmount(amountDecimal, fromSwapCurrencyStatus.currency.decimals)
val dexTransaction = swapData.transaction as ExpressTransactionModel.DEX
val dataToSign = dexTransaction.txData
val amountToSend = createNativeAmountForDex(txValue, fromSwapCurrencyStatus.currency.network)
val txData = createTransactionUseCase(
amount = amountToSend,
fee = swapFee.fee,
memo = null,
destination = swapData.transaction.txTo,
userWalletId = fromSwapCurrencyStatus.userWalletId,
network = toSwapCurrencyStatus.currency.network,
txExtras = createDexTxExtras(
dataToSign,
fromSwapCurrencyStatus.currency.network,
swapFee.fee.getGasLimit(),
),
).getOrElse { error ->
val isYieldSwap = fromSwapCurrencyStatus.isYieldSwapActive
val fromCurrency = fromSwapCurrencyStatus.currency
val txDataResult = if (isYieldSwap && fromCurrency is CryptoCurrency.Token) {
val spenderAddress = dexTransaction.allowanceContract
?: return SwapTransactionState.Error.UnknownError
createYieldSwapDexTransaction(
fromSwapCurrencyStatus = fromSwapCurrencyStatus,
swapData = swapData,
dexCallData = dataToSign,
amount = amountDecimal,
fee = swapFee.fee,
spenderAddress = spenderAddress,
)
} else {
val txValue = requireNotNull(swapData.transaction.txValue) { "txValue is null" }
val amountToSend = createNativeAmountForDex(txValue, fromCurrency.network)
createTransactionUseCase(
amount = amountToSend,
fee = swapFee.fee,
memo = null,
destination = swapData.transaction.txTo,
userWalletId = fromSwapCurrencyStatus.userWalletId,
network = fromCurrency.network,
txExtras = createDexTxExtras(
dataToSign,
fromCurrency.network,
swapFee.fee.getGasLimit(),
),
)
}
val txData = txDataResult.getOrElse { error ->
TangemLogger.e("Failed to create swap dex tx data", error)
return SwapTransactionState.Error.UnknownError
}
val payInAddress = if (isYieldSwap && fromCurrency is CryptoCurrency.Token) {
swapData.transaction.txTo
} else if (txData is TransactionData.Uncompiled) {
getPayoutAddress(txData)
} else {
swapData.transaction.txTo
}
return handleSwapResult(
fromSwapCurrencyStatus = fromSwapCurrencyStatus,
toSwapCurrencyStatus = toSwapCurrencyStatus,
@ -664,7 +709,7 @@ internal class SwapInteractorImpl @Inject constructor(
swapData = swapData,
amount = amount,
txData = txData,
payInAddress = getPayoutAddress(txData),
payInAddress = payInAddress,
)
}
@ -1001,11 +1046,23 @@ internal class SwapInteractorImpl @Inject constructor(
val transaction = swapData?.transaction as? ExpressTransactionModel.DEX
?: return GetFeeError.UnknownError.left()
return dexSwapFeeCalculator.calculate(
fromSwapCurrencyStatus = fromStatus,
transaction = transaction,
selectedToken = selectedFeeToken,
).fold(
val dexFeeResultEither = if (fromStatus.isYieldSwapActive && fromStatus.currency is CryptoCurrency.Token) {
val network = (fromStatus.currency as CryptoCurrency.Token).network
val yieldModuleAddress = yieldModuleAddressProvider.getOrFetch(fromStatus.userWalletId, network)
dexSwapFeeCalculator.calculateYield(
fromSwapCurrencyStatus = fromStatus,
transaction = transaction,
yieldModuleAddress = yieldModuleAddress,
)
} else {
dexSwapFeeCalculator.calculate(
fromSwapCurrencyStatus = fromStatus,
transaction = transaction,
selectedToken = selectedFeeToken,
)
}
return dexFeeResultEither.fold(
ifLeft = { error -> GetFeeError.DataError(error).left() },
ifRight = { dexFeeResult ->
val feeToken = selectedFeeToken
@ -1021,6 +1078,42 @@ internal class SwapInteractorImpl @Inject constructor(
)
}
private suspend fun createYieldSwapDexTransaction(
fromSwapCurrencyStatus: SwapCurrencyStatus,
swapData: SwapDataModel,
dexCallData: String,
amount: BigDecimal,
fee: Fee,
spenderAddress: String,
): Either<Throwable, TransactionData> {
val fromCurrency = fromSwapCurrencyStatus.currency as CryptoCurrency.Token
val network = fromCurrency.network
val yieldModuleAddress = yieldModuleAddressProvider.getOrFetch(fromSwapCurrencyStatus.userWalletId, network)
?: return Either.Left(IllegalStateException("Yield module address is not available for ${network.id}"))
val wrappedCallData = dexSwapFeeCalculator.buildYieldSwapCallData(
fromSwapCurrencyStatus = fromSwapCurrencyStatus,
txTo = swapData.transaction.txTo,
dexCallData = dexCallData,
amount = amount,
spenderAddress = spenderAddress,
)
val txExtras = createTransactionExtrasUseCase(
callData = wrappedCallData,
network = network,
gasLimit = fee.getGasLimit()?.toBigInteger(),
).getOrNull() ?: error("Failed to create yield swap extras")
return createTransactionUseCase(
amount = createNativeAmountForDex("0", network),
fee = fee,
memo = null,
destination = yieldModuleAddress,
userWalletId = fromSwapCurrencyStatus.userWalletId,
network = network,
txExtras = txExtras,
)
}
/**
* [REDACTED_TASK_KEY] CEX branch of [loadSwapFee]. Native-fallback behavior is preserved: when
* [selectedFeeToken] is null the gasless use case (invoked inside [CexSwapFeeCalculator])
@ -1501,6 +1594,7 @@ internal class SwapInteractorImpl @Inject constructor(
toSwapCurrencyStatus: SwapCurrencyStatus,
amount: SwapAmount,
expressOperationType: ExpressOperationType,
quoteAllowanceContract: String? = null,
): SwapState {
val fromNetworkAddress = fromSwapCurrencyStatus.status.value.networkAddress
val dexFromAddress = fromNetworkAddress?.defaultAddress?.value.orEmpty()
@ -1521,7 +1615,14 @@ internal class SwapInteractorImpl @Inject constructor(
toAddress = dexToAddress,
refundAddress = fromNetworkAddress?.defaultAddress?.value,
expressOperationType = expressOperationType,
).fold(
).map { swapData ->
val dexTx = swapData.transaction as? ExpressTransactionModel.DEX
if (dexTx != null && quoteAllowanceContract != null && dexTx.allowanceContract == null) {
swapData.copy(transaction = dexTx.copy(allowanceContract = quoteAllowanceContract))
} else {
swapData
}
}.fold(
ifRight = { swapData ->
val preparedSwapConfigState = PreparedSwapConfigState(
balanceStatus = SwapBalanceStatus.Pending,
@ -1640,17 +1741,31 @@ internal class SwapInteractorImpl @Inject constructor(
)
}
val isYieldSwap = fromSwapCurrencyStatus.isYieldSwapActive && fromToken is CryptoCurrency.Token
val spenderAddress = if (isYieldSwap) {
yieldModuleAddressProvider.getOrFetch(fromSwapCurrencyStatus.userWalletId, fromToken.network)
?: run {
TangemLogger.e(
"Yield-swap approval skipped: yield-module address unresolved for " +
"walletId=${fromSwapCurrencyStatus.userWalletId} network=${fromToken.network.rawId}",
)
return quotesLoadedState.copy(permissionState = PermissionDataState.Empty)
}
} else {
requireNotNull(quoteModel.allowanceContract) { "spenderAddress cant be null" }
}
val allowanceInfo = getAllowanceInfoUseCase(
userWalletId = fromSwapCurrencyStatus.userWalletId,
cryptoCurrency = fromToken,
spenderAddress = requireNotNull(quoteModel.allowanceContract) { "spenderAddress cant be null" },
spenderAddress = spenderAddress,
requiredAmount = swapAmount.value,
).getOrNull()
return quotesLoadedState.copy(
permissionState = PermissionDataState.PermissionRequired(
isResetApproval = allowanceInfo is AllowanceInfo.ResetNeeded,
spenderAddress = quoteModel.allowanceContract,
spenderAddress = spenderAddress,
),
)
}

View file

@ -9,6 +9,7 @@ import com.tangem.domain.transaction.usecase.gasless.EstimateFeeForGaslessTxUseC
import com.tangem.domain.transaction.usecase.gasless.EstimateFeeForTokenUseCase
import com.tangem.domain.transaction.usecase.gasless.GetFeeForTokenUseCase
import com.tangem.domain.walletmanager.WalletManagersFacade
import com.tangem.domain.yield.supply.usecase.WrapYieldSwapCallDataWithUpgradeUseCase
import com.tangem.feature.swap.domain.*
import com.tangem.feature.swap.domain.api.SwapFeedbackRepository
import com.tangem.feature.swap.domain.api.SwapRepository
@ -75,6 +76,7 @@ internal class SwapDomainModule {
createTransactionExtrasUseCase: CreateTransactionDataExtrasUseCase,
walletManagersFacade: WalletManagersFacade,
@SwapDexGasLimit patchEthGasLimitForSwap: PatchEthGasLimitForSwap,
wrapYieldSwapCallDataWithUpgradeUseCase: WrapYieldSwapCallDataWithUpgradeUseCase,
): DexSwapFeeCalculator = DexSwapFeeCalculator(
getFeeUseCase = getFeeUseCase,
getEthSpecificFeeUseCase = getEthSpecificFeeUseCase,
@ -82,6 +84,7 @@ internal class SwapDomainModule {
createTransactionExtrasUseCase = createTransactionExtrasUseCase,
walletManagersFacade = walletManagersFacade,
patchEthGasLimitForSwap = patchEthGasLimitForSwap,
wrapYieldSwapCallDataWithUpgradeUseCase = wrapYieldSwapCallDataWithUpgradeUseCase,
)
@Provides

View file

@ -7,8 +7,13 @@ import com.tangem.blockchain.blockchains.solana.SolanaTransactionHelper
import com.tangem.blockchain.common.Amount
import com.tangem.blockchain.common.Blockchain
import com.tangem.blockchain.common.TransactionData
import com.tangem.blockchain.common.smartcontract.SmartContractCallData
import com.tangem.blockchain.common.transaction.TransactionFee
import com.tangem.blockchain.yieldsupply.providers.YieldModuleUpgradeUnavailableException
import com.tangem.blockchain.yieldsupply.providers.YieldModuleVersionIndeterminateException
import com.tangem.blockchain.yieldsupply.providers.ethereum.yield.EthereumYieldSupplySwapCallData
import com.tangem.blockchainsdk.utils.fromNetworkId
import com.tangem.common.extensions.hexToBytes
import com.tangem.domain.models.currency.CryptoCurrency
import com.tangem.domain.models.currency.CryptoCurrencyStatus
import com.tangem.domain.models.network.Network
@ -19,12 +24,14 @@ import com.tangem.domain.transaction.usecase.GetEthSpecificFeeUseCase
import com.tangem.domain.transaction.usecase.GetFeeUseCase
import com.tangem.domain.transaction.usecase.gasless.GetFeeForTokenUseCase
import com.tangem.domain.walletmanager.WalletManagersFacade
import com.tangem.domain.yield.supply.usecase.WrapYieldSwapCallDataWithUpgradeUseCase
import com.tangem.feature.swap.domain.models.ExpressDataError
import com.tangem.feature.swap.domain.models.domain.ExpressTransactionModel
import com.tangem.lib.crypto.BlockchainUtils.SOLANA_TRANSACTION_SIZE_THRESHOLD_BYTES
import com.tangem.lib.crypto.BlockchainUtils.isSolana
import com.tangem.utils.logging.TangemLogger
import java.math.BigDecimal
import java.math.BigInteger
/**
* Calculates the on-chain transaction fee for a DEX swap.
@ -52,6 +59,7 @@ class DexSwapFeeCalculator(
private val createTransactionExtrasUseCase: CreateTransactionDataExtrasUseCase,
private val walletManagersFacade: WalletManagersFacade,
private val patchEthGasLimitForSwap: PatchEthGasLimitForSwap,
private val wrapYieldSwapCallDataWithUpgradeUseCase: WrapYieldSwapCallDataWithUpgradeUseCase,
) {
suspend fun calculate(
@ -115,6 +123,134 @@ class DexSwapFeeCalculator(
}
}
/**
* Yield-mode DEX fee path: routes the swap through the user's yield module proxy.
*
* Native fee is computed for a [TransactionData.Uncompiled] addressed to [yieldModuleAddress],
* carrying the wrapped call data produced by [buildYieldSwapCallData]. The 12% gas-limit bump
* is applied to match the non-yield DEX flow.
*
* Fallback to [GetEthSpecificFeeUseCase] (with the gas limit carried by the Express transaction
* model) is applied in two cases:
* - [yieldModuleAddress] is `null` yield module address could not be resolved upstream;
* - the fee estimation call throws `IllegalStateException` (e.g. payload too large).
*
* Yield-module errors ([YieldModuleUpgradeUnavailableException],
* [YieldModuleVersionIndeterminateException]) are mapped to [ExpressDataError.UnknownError]
* to keep the unified error surface a single type.
*/
suspend fun calculateYield(
fromSwapCurrencyStatus: SwapCurrencyStatus,
transaction: ExpressTransactionModel.DEX,
yieldModuleAddress: String?,
): Either<ExpressDataError, DexFeeResult> = either {
val fromCurrency = fromSwapCurrencyStatus.currency as? CryptoCurrency.Token
?: raise(ExpressDataError.UnknownError())
val network = fromCurrency.network
val nativeBalance = walletManagersFacade.getNativeTokenBalance(
userWalletId = fromSwapCurrencyStatus.userWalletId,
networkId = network.rawId,
derivationPath = network.derivationPath.value,
)
if (nativeBalance.signum() == 0) raise(ExpressDataError.UnknownError())
if (yieldModuleAddress == null) {
val gasLimit = transaction.gas ?: raise(ExpressDataError.UnknownError())
return@either ethSpecificFeeFallback(fromSwapCurrencyStatus, gasLimit).bind()
}
val spenderAddress = transaction.allowanceContract
?: raise(ExpressDataError.UnknownError())
val rawFee = try {
val wrappedCallData = buildYieldSwapCallData(
fromSwapCurrencyStatus = fromSwapCurrencyStatus,
txTo = transaction.txTo,
dexCallData = transaction.txData,
amount = transaction.fromAmount.value,
spenderAddress = spenderAddress,
)
val extras = createTransactionExtrasUseCase(
callData = wrappedCallData,
network = network,
).getOrNull() ?: raise(ExpressDataError.UnknownError())
val transactionData = TransactionData.Uncompiled(
amount = createNativeAmountForDex("0", network),
destinationAddress = yieldModuleAddress,
fee = null,
sourceAddress = transaction.txFrom,
extras = extras,
)
getFeeUseCase(
transactionData = transactionData,
network = network,
userWallet = fromSwapCurrencyStatus.userWallet,
).getOrNull() ?: raise(ExpressDataError.UnknownError())
} catch (_: YieldModuleUpgradeUnavailableException) {
raise(ExpressDataError.UnknownError())
} catch (_: YieldModuleVersionIndeterminateException) {
raise(ExpressDataError.UnknownError())
} catch (_: IllegalStateException) {
val gasLimit = transaction.gas ?: raise(ExpressDataError.UnknownError())
return@either ethSpecificFeeFallback(fromSwapCurrencyStatus, gasLimit).bind()
}
val patched = patchEthGasLimitForSwap(rawFee)
DexFeeResult(
transactionFee = TransactionFeeResult.Loaded(patched),
otherNativeFee = BigDecimal.ZERO,
gas = transaction.gas,
)
}
/**
* Wraps a DEX call data into a yield-supply swap call data, ready to be sent through the
* user's yield module. Shared with [SwapInteractorImpl.createYieldSwapDexTransaction], which
* is why this helper is exposed at the calculator level rather than kept private.
*/
suspend fun buildYieldSwapCallData(
fromSwapCurrencyStatus: SwapCurrencyStatus,
txTo: String,
dexCallData: String,
amount: BigDecimal,
spenderAddress: String,
): SmartContractCallData {
val fromCurrency = fromSwapCurrencyStatus.currency as CryptoCurrency.Token
val amountInWei = amount.movePointRight(fromCurrency.decimals).toBigInteger()
val dexCallDataBytes = dexCallData.removePrefix("0x").hexToBytes()
val swapCallData = EthereumYieldSupplySwapCallData(
tokenIn = fromCurrency.contractAddress,
amountIn = amountInWei,
target = txTo,
spender = spenderAddress,
swapData = dexCallDataBytes,
)
return wrapYieldSwapCallDataWithUpgradeUseCase(
userWalletId = fromSwapCurrencyStatus.userWalletId,
network = fromCurrency.network,
callData = swapCallData,
)
}
private suspend fun ethSpecificFeeFallback(
fromSwapCurrencyStatus: SwapCurrencyStatus,
gasLimit: BigInteger,
): Either<ExpressDataError, DexFeeResult> = either {
val fee = getEthSpecificFeeUseCase(
userWallet = fromSwapCurrencyStatus.userWallet,
cryptoCurrency = fromSwapCurrencyStatus.currency,
gasLimit = gasLimit,
).getOrNull() ?: raise(ExpressDataError.UnknownError())
val patched = patchEthGasLimitForSwap(fee)
DexFeeResult(
transactionFee = TransactionFeeResult.Loaded(patched),
otherNativeFee = BigDecimal.ZERO,
gas = gasLimit,
)
}
@Suppress("CyclomaticComplexMethod")
private suspend fun getFeeDataForDexSwap(
fromSwapCurrencyStatus: SwapCurrencyStatus,

View file

@ -30,7 +30,7 @@ sealed class ExpressTransactionModel {
val txData: String,
val otherNativeFeeWei: BigDecimal?,
val gas: BigInteger?,
val allowanceContract: String?,
val allowanceContract: String? = null,
) : ExpressTransactionModel()
data class CEX(

View file

@ -21,6 +21,7 @@ import com.tangem.feature.swap.domain.models.SwapAmount
import com.tangem.feature.swap.domain.models.domain.ExchangeProviderType
import com.tangem.feature.swap.domain.models.domain.ExpressTransactionModel
import com.tangem.feature.swap.domain.models.domain.SwapDataModel
import com.tangem.feature.swap.domain.models.ui.PermissionDataState
import com.tangem.feature.swap.domain.models.ui.SwapState
import io.mockk.coEvery
import io.mockk.every
@ -872,6 +873,210 @@ internal class SwapInteractorImplFindBestQuoteTest : SwapInteractorImplTestBase(
assertThat(result[cexProvider]).isInstanceOf(SwapState.QuotesLoadedState::class.java)
}
}
@Nested
inner class YieldSwapApprovalPath {
private val yieldProxyAddress = "0xYieldModuleProxy"
private val yieldTokenContract = "0xTokenContract"
@BeforeEach
fun enableYieldSwap() {
every { swapFeatureToggles.isYieldSwapEnabled } returns true
coEvery {
yieldModuleAddressProvider.getOrFetch(any(), any())
} returns yieldProxyAddress
}
@Test
fun `should proceed to QuotesLoadedState when yield-supply is active and isAllowedToSpend is true`() = runTest {
// Given — yield active, approve to proxy in place → swap proceeds via loadDexSwapDataNoFee
val dexProvider = buildSwapProvider(ExchangeProviderType.DEX)
val fromStatus = buildSwapCurrencyStatus(
networkRawId = ethNetwork,
contractAddress = yieldTokenContract,
isCoin = false,
amount = BigDecimal("10"),
yieldSupplyActive = true,
yieldSupplyAllowedToSpend = true,
)
val toStatus = buildSwapCurrencyStatus(networkRawId = btcNetwork)
val quoteModel = buildQuoteModel()
val swapData = buildSwapDataModelDex()
coEvery {
repository.findBestQuote(
userWallet = any(), fromContractAddress = any(), fromNetwork = any(),
toContractAddress = any(), toNetwork = any(), fromAmount = any(),
fromDecimals = any(), toDecimals = any(),
providerId = dexProvider.providerId, rateType = any(),
)
} returns quoteModel.right()
coEvery {
repository.getExchangeData(
userWallet = any(), fromContractAddress = any(), fromNetwork = any(),
toContractAddress = any(), fromAddress = any(), toNetwork = any(),
fromAmount = any(), fromDecimals = any(), toDecimals = any(),
providerId = dexProvider.providerId, rateType = any(), toAddress = any(),
expressOperationType = any(), refundAddress = any(),
)
} returns swapData.right()
// When
val result = sut.findBestQuote(
fromSwapCurrencyStatus = fromStatus,
toSwapCurrencyStatus = toStatus,
providers = listOf(dexProvider),
amountToSwap = "1.0",
reduceBalanceBy = BigDecimal.ZERO,
)
// Then — proceeds (no PermissionRequired), permissionState is Empty
val state = result[dexProvider]
assertThat(state).isInstanceOf(SwapState.QuotesLoadedState::class.java)
val loaded = state as SwapState.QuotesLoadedState
assertThat(loaded.permissionState).isEqualTo(PermissionDataState.Empty)
}
@Test
fun `should request approval to yield-module proxy when isAllowedToSpend is false`() = runTest {
// Given — yield active, approve to proxy revoked → flow must surface PermissionRequired
val dexProvider = buildSwapProvider(ExchangeProviderType.DEX)
val fromStatus = buildSwapCurrencyStatus(
networkRawId = ethNetwork,
contractAddress = yieldTokenContract,
isCoin = false,
amount = BigDecimal("10"),
yieldSupplyActive = true,
yieldSupplyAllowedToSpend = false,
)
val toStatus = buildSwapCurrencyStatus(networkRawId = btcNetwork)
val quoteModel = buildQuoteModel(allowanceContract = "0xDexRouterShouldNotBeUsed")
coEvery {
repository.findBestQuote(
userWallet = any(), fromContractAddress = any(), fromNetwork = any(),
toContractAddress = any(), toNetwork = any(), fromAmount = any(),
fromDecimals = any(), toDecimals = any(),
providerId = dexProvider.providerId, rateType = any(),
)
} returns quoteModel.right()
// When
val result = sut.findBestQuote(
fromSwapCurrencyStatus = fromStatus,
toSwapCurrencyStatus = toStatus,
providers = listOf(dexProvider),
amountToSwap = "1.0",
reduceBalanceBy = BigDecimal.ZERO,
)
// Then — PermissionRequired with spender = yield-module proxy (not DEX router)
val state = result[dexProvider]
assertThat(state).isInstanceOf(SwapState.QuotesLoadedState::class.java)
val loaded = state as SwapState.QuotesLoadedState
assertThat(loaded.permissionState).isInstanceOf(PermissionDataState.PermissionRequired::class.java)
val required = loaded.permissionState as PermissionDataState.PermissionRequired
assertThat(required.spenderAddress).isEqualTo(yieldProxyAddress)
}
@Test
fun `should set isResetApproval=true when yield-token allowance requires reset before re-approval`() = runTest {
// Given — Tether-like token: any non-zero allowance must be reset to zero before re-approve.
// Yield approve to proxy was revoked → onchain allowance is partial → ResetNeeded.
val dexProvider = buildSwapProvider(ExchangeProviderType.DEX)
val fromStatus = buildSwapCurrencyStatus(
networkRawId = ethNetwork,
contractAddress = yieldTokenContract,
isCoin = false,
amount = BigDecimal("10"),
yieldSupplyActive = true,
yieldSupplyAllowedToSpend = false,
)
val toStatus = buildSwapCurrencyStatus(networkRawId = btcNetwork)
val quoteModel = buildQuoteModel(allowanceContract = "0xDexRouterIgnoredForYield")
coEvery {
repository.findBestQuote(
userWallet = any(), fromContractAddress = any(), fromNetwork = any(),
toContractAddress = any(), toNetwork = any(), fromAmount = any(),
fromDecimals = any(), toDecimals = any(),
providerId = dexProvider.providerId, rateType = any(),
)
} returns quoteModel.right()
// Override default Enough stub: simulate partial-allowance state for yield-proxy spender.
coEvery {
getAllowanceInfoUseCase.invoke(
userWalletId = any(),
cryptoCurrency = any(),
spenderAddress = yieldProxyAddress,
requiredAmount = any(),
)
} returns (
AllowanceInfo.ResetNeeded(
allowance = BigDecimal("0.5"),
requiredAmount = BigDecimal("1"),
) as AllowanceInfo
).right()
// When
val result = sut.findBestQuote(
fromSwapCurrencyStatus = fromStatus,
toSwapCurrencyStatus = toStatus,
providers = listOf(dexProvider),
amountToSwap = "1.0",
reduceBalanceBy = BigDecimal.ZERO,
)
// Then — PermissionRequired with isResetApproval=true and spender = yield-module proxy
val state = result[dexProvider]
assertThat(state).isInstanceOf(SwapState.QuotesLoadedState::class.java)
val loaded = state as SwapState.QuotesLoadedState
assertThat(loaded.permissionState).isInstanceOf(PermissionDataState.PermissionRequired::class.java)
val required = loaded.permissionState as PermissionDataState.PermissionRequired
assertThat(required.spenderAddress).isEqualTo(yieldProxyAddress)
assertThat(required.isResetApproval).isTrue()
}
@Test
fun `should fallback to no-permission state when yield-module proxy address is unresolvable`() = runTest {
// Given — yield store returns null (e.g. network unreachable on first resolve)
coEvery { yieldModuleAddressProvider.getOrFetch(any(), any()) } returns null
val dexProvider = buildSwapProvider(ExchangeProviderType.DEX)
val fromStatus = buildSwapCurrencyStatus(
networkRawId = ethNetwork,
contractAddress = yieldTokenContract,
isCoin = false,
amount = BigDecimal("10"),
yieldSupplyActive = true,
yieldSupplyAllowedToSpend = false,
)
val toStatus = buildSwapCurrencyStatus(networkRawId = btcNetwork)
val quoteModel = buildQuoteModel(allowanceContract = "0xDexRouter")
coEvery {
repository.findBestQuote(
userWallet = any(), fromContractAddress = any(), fromNetwork = any(),
toContractAddress = any(), toNetwork = any(), fromAmount = any(),
fromDecimals = any(), toDecimals = any(),
providerId = dexProvider.providerId, rateType = any(),
)
} returns quoteModel.right()
// When
val result = sut.findBestQuote(
fromSwapCurrencyStatus = fromStatus,
toSwapCurrencyStatus = toStatus,
providers = listOf(dexProvider),
amountToSwap = "1.0",
reduceBalanceBy = BigDecimal.ZERO,
)
// Then — falls back to PermissionDataState.Empty (no approval UI shown to avoid bogus DEX-router approve)
val state = result[dexProvider]
assertThat(state).isInstanceOf(SwapState.QuotesLoadedState::class.java)
val loaded = state as SwapState.QuotesLoadedState
assertThat(loaded.permissionState).isEqualTo(PermissionDataState.Empty)
}
}
}
// region — test-local helpers

View file

@ -33,6 +33,7 @@ import com.tangem.domain.tokens.repository.CurrencyChecksRepository
import com.tangem.domain.transaction.usecase.*
import com.tangem.domain.transaction.usecase.gasless.CreateAndSendGaslessTransactionUseCase
import com.tangem.domain.walletmanager.WalletManagersFacade
import com.tangem.domain.yield.supply.YieldModuleAddressProvider
import com.tangem.feature.swap.domain.api.SwapRepository
import com.tangem.feature.swap.domain.fee.CexSwapFeeCalculator
import com.tangem.feature.swap.domain.fee.DexSwapFeeCalculator
@ -41,6 +42,7 @@ import com.tangem.feature.swap.domain.models.SwapAmount
import com.tangem.feature.swap.domain.models.domain.*
import com.tangem.feature.swap.domain.models.ui.AmountFormatter
import com.tangem.feature.swap.domain.models.ui.SwapFee
import com.tangem.features.swap.SwapFeatureToggles
import io.mockk.clearAllMocks
import io.mockk.every
import io.mockk.mockk
@ -84,6 +86,8 @@ internal open class SwapInteractorImplTestBase {
protected val getSwapPairUseCase: GetSwapPairUseCase = mockk(relaxed = true)
protected val dexSwapFeeCalculator: DexSwapFeeCalculator = mockk(relaxed = true)
protected val cexSwapFeeCalculator: CexSwapFeeCalculator = mockk(relaxed = true)
protected val swapFeatureToggles: SwapFeatureToggles = mockk(relaxed = true)
protected val yieldModuleAddressProvider: YieldModuleAddressProvider = mockk(relaxed = true)
// endregion
@ -115,6 +119,8 @@ internal open class SwapInteractorImplTestBase {
getSwapPairUseCase = getSwapPairUseCase,
dexSwapFeeCalculator = dexSwapFeeCalculator,
cexSwapFeeCalculator = cexSwapFeeCalculator,
swapFeatureToggles = swapFeatureToggles,
yieldModuleAddressProvider = yieldModuleAddressProvider,
)
}
@ -158,6 +164,7 @@ internal fun buildSwapCurrencyStatus(
decimals: Int = 18,
userWalletId: UserWalletId = UserWalletId(stringValue = "deadbeef"),
yieldSupplyActive: Boolean = false,
yieldSupplyAllowedToSpend: Boolean = true,
): SwapCurrencyStatus {
val networkId = mockk<Network.ID>(relaxed = true) {
every { rawId } returns Network.RawID(networkRawId)
@ -196,6 +203,7 @@ internal fun buildSwapCurrencyStatus(
val maybeYield: YieldSupplyStatus? = if (yieldSupplyActive) {
mockk<YieldSupplyStatus>(relaxed = true) {
every { isActive } returns true
every { isAllowedToSpend } returns yieldSupplyAllowedToSpend
}
} else {
null

View file

@ -20,6 +20,7 @@ import com.tangem.domain.transaction.usecase.GetEthSpecificFeeUseCase
import com.tangem.domain.transaction.usecase.GetFeeUseCase
import com.tangem.domain.transaction.usecase.gasless.GetFeeForTokenUseCase
import com.tangem.domain.walletmanager.WalletManagersFacade
import com.tangem.domain.yield.supply.usecase.WrapYieldSwapCallDataWithUpgradeUseCase
import com.tangem.feature.swap.domain.buildSwapCurrencyStatus
import com.tangem.feature.swap.domain.models.ExpressDataError
import com.tangem.feature.swap.domain.models.SwapAmount
@ -59,6 +60,7 @@ internal class DexSwapFeeCalculatorTest {
private val getFeeForTokenUseCase: GetFeeForTokenUseCase = mockk(relaxed = true)
private val createTransactionExtrasUseCase: CreateTransactionDataExtrasUseCase = mockk(relaxed = true)
private val walletManagersFacade: WalletManagersFacade = mockk(relaxed = true)
private val wrapYieldSwapCallDataWithUpgradeUseCase: WrapYieldSwapCallDataWithUpgradeUseCase = mockk(relaxed = true)
private val dexBump = PatchEthGasLimitForSwap(percentage = PatchEthGasLimitForSwap.DEX_PERCENTAGE)
@ -70,6 +72,7 @@ internal class DexSwapFeeCalculatorTest {
createTransactionExtrasUseCase = createTransactionExtrasUseCase,
walletManagersFacade = walletManagersFacade,
patchEthGasLimitForSwap = dexBump,
wrapYieldSwapCallDataWithUpgradeUseCase = wrapYieldSwapCallDataWithUpgradeUseCase,
)
}

View file

@ -9,6 +9,10 @@ internal class DefaultSwapFeatureToggles @Inject constructor(
featureTogglesManager: FeatureTogglesManager,
) : SwapFeatureToggles {
override val isYieldSwapEnabled: Boolean = featureTogglesManager.isFeatureEnabled(
toggle = FeatureToggles.TWI_1326_YIELD_MODE_SWAP_ENABLED,
)
override val isSwapSwitchToTransferEnabled: Boolean = featureTogglesManager.isFeatureEnabled(
toggle = FeatureToggles.AND_15207_SWAP_SWITCH_TO_TRANSFER_ENABLED,
)

View file

@ -29,6 +29,7 @@ sealed class ProviderState {
val additionalBadge: AdditionalBadge,
val percentLowerThenBest: PercentDifference = PercentDifference.Empty,
val namePrefix: PrefixType,
val approvalSettings: ApprovalSettings = ApprovalSettings.Empty,
override val onProviderClick: (String) -> Unit,
) : ProviderState()
@ -61,6 +62,14 @@ sealed class ProviderState {
enum class PrefixType {
NONE, PROVIDED_BY
}
@Immutable
sealed class ApprovalSettings {
data object Empty : ApprovalSettings()
data class Content(
val onApprovalSelectClick: () -> Unit,
) : ApprovalSettings()
}
}
@Immutable

View file

@ -4,26 +4,32 @@ import android.content.res.Configuration
import androidx.compose.animation.AnimatedContent
import androidx.compose.foundation.background
import androidx.compose.foundation.clickable
import androidx.compose.foundation.interaction.MutableInteractionSource
import androidx.compose.foundation.layout.*
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.material3.CircularProgressIndicator
import androidx.compose.material3.Icon
import androidx.compose.material3.Text
import androidx.compose.material3.ripple
import androidx.compose.runtime.Composable
import androidx.compose.runtime.remember
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.graphics.vector.ImageVector
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.platform.testTag
import androidx.compose.ui.res.painterResource
import androidx.compose.ui.res.vectorResource
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.tooling.preview.PreviewParameter
import androidx.compose.ui.tooling.preview.datasource.CollectionPreviewParameterProvider
import androidx.compose.ui.unit.dp
import coil.compose.SubcomposeAsyncImage
import coil.request.ImageRequest
import com.tangem.core.ui.R
import com.tangem.core.ui.components.RectangleShimmer
import com.tangem.core.ui.components.SpacerWMax
import com.tangem.core.ui.extensions.resolveReference
import com.tangem.core.ui.extensions.stringReference
import com.tangem.core.ui.extensions.stringResourceSafe
@ -194,6 +200,23 @@ private fun ProviderContentState(
}
}
}
if (state.approvalSettings is ProviderState.ApprovalSettings.Content) {
SpacerWMax()
Icon(
imageVector = ImageVector.vectorResource(R.drawable.ic_filter_default_24),
contentDescription = null,
tint = TangemTheme.colors.icon.informative,
modifier = Modifier
.padding(end = 14.dp)
.size(20.dp)
.clickable(
indication = ripple(false),
interactionSource = remember { MutableInteractionSource() },
onClick = state.approvalSettings.onApprovalSelectClick,
),
)
}
}
ProviderChevron(selectionType = state.selectionType, isSelected = isSelected)
@ -442,10 +465,9 @@ private fun ProviderItemPreview(
@PreviewParameter(ProviderItemParameterProvider::class) state: Pair<ProviderState, Boolean>,
) {
TangemThemePreview {
ProviderItem(
ProviderItemBlock(
modifier = Modifier.background(TangemTheme.colors.background.action),
state = state.first,
isSelected = state.second,
)
}
}
@ -460,22 +482,28 @@ private class ProviderItemParameterProvider : CollectionPreviewParameterProvider
subtitle = stringReference(value = "0,64554846 DAI ≈ 1 MATIC"),
additionalBadge = ProviderState.AdditionalBadge.Empty,
percentLowerThenBest = PercentDifference.Value(value = 12.0f),
selectionType = ProviderState.SelectionType.SELECT,
selectionType = ProviderState.SelectionType.NONE,
namePrefix = ProviderState.PrefixType.PROVIDED_BY,
approvalSettings = ProviderState.ApprovalSettings.Empty,
onProviderClick = {},
)
val contentState2 = contentState.copy(
val contentStatePermissionRequired = contentState.copy(
subtitle = stringReference(value = "1 132,46 MATIC"),
additionalBadge = ProviderState.AdditionalBadge.PermissionRequired,
percentLowerThenBest = PercentDifference.Value(value = 5f),
)
val contentStatePermissionIntegrated = contentState.copy(
subtitle = stringReference(value = "1 132,46 MATIC"),
percentLowerThenBest = PercentDifference.Value(value = 5f),
approvalSettings = ProviderState.ApprovalSettings.Content({}),
)
val unavailableState = ProviderState.Unavailable(
id = "1",
name = "1inch",
type = "DEX",
iconUrl = "",
alertText = stringReference(value = "Not available"),
selectionType = ProviderState.SelectionType.SELECT,
selectionType = ProviderState.SelectionType.NONE,
onProviderClick = {},
)
val loadingState = ProviderState.Loading()
@ -483,8 +511,11 @@ private class ProviderItemParameterProvider : CollectionPreviewParameterProvider
add(contentState to true)
add(contentState to false)
add(contentState2 to true)
add(contentState2 to false)
add(contentStatePermissionRequired to true)
add(contentStatePermissionRequired to false)
add(contentStatePermissionIntegrated to true)
add(contentStatePermissionIntegrated to false)
add(unavailableState to true)
add(unavailableState to false)

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