Updated on 2026-08-14

This commit is contained in:
Tangem 2025-12-22 18:48:34 +03:00
commit 1a13142dac
244 changed files with 9915 additions and 1592 deletions

View file

@ -139,10 +139,10 @@ abstract class BaseTestCase : TestCase(
return ApplicationInjectionExecutionRule( return ApplicationInjectionExecutionRule(
toggleStates = mapOf( toggleStates = mapOf(
"NEW_TOKEN_RECEIVE_ENABLED" to true, "NEW_TOKEN_RECEIVE_ENABLED" to true,
"WALLET_BALANCE_FETCHER_ENABLED" to true, "SWAP_REDESIGN_ENABLED" to false,
"SWAP_REDESIGN_ENABLED" to true,
"NEW_ONRAMP_MAIN_ENABLED" to true, "NEW_ONRAMP_MAIN_ENABLED" to true,
"HOT_WALLET_ENABLED" to true "HOT_WALLET_ENABLED" to true,
"YIELD_SUPPLY_FEATURE_ENABLED" to true
) )
) )
} }

View file

@ -8,6 +8,7 @@ object TestConstants {
const val ETHEREUM_RECIPIENT_ADDRESS = "0x5aa711F440Eb6d4361148bBD89d03464628ace84" const val ETHEREUM_RECIPIENT_ADDRESS = "0x5aa711F440Eb6d4361148bBD89d03464628ace84"
const val ETHEREUM_RECIPIENT_SHORTENED_ADDRESS = "0x5aa711F440Eb6d43...89d03464628ace84" const val ETHEREUM_RECIPIENT_SHORTENED_ADDRESS = "0x5aa711F440Eb6d43...89d03464628ace84"
const val BITCOIN_ADDRESS = "bc1qtg9aa6jcpqtvun0pe0uct7sxm8nq2nsxfmfxm3" const val BITCOIN_ADDRESS = "bc1qtg9aa6jcpqtvun0pe0uct7sxm8nq2nsxfmfxm3"
const val BITCOIN_RECIPIENT_ADDRESS = "bc1qt90qc0na7z05nh63kyd78tujfc8vqv6sl7e4a9"
const val CARDANO_ADDRESS = const val CARDANO_ADDRESS =
"addr1q8f9499e58k4hhfd9vhawprxt3xd94x7rmlyp33ee4xkatakcl2zgkrg0p6ceqkndtkw4cumfe9enhdph8yhuswn785srksm9p" "addr1q8f9499e58k4hhfd9vhawprxt3xd94x7rmlyp33ee4xkatakcl2zgkrg0p6ceqkndtkw4cumfe9enhdph8yhuswn785srksm9p"
const val SOLANA_RECIPIENT_ADDRESS = "5fcy9woa8Di1QHcce65CsV3XKrxdB2pD4HJx5xx82ipM" const val SOLANA_RECIPIENT_ADDRESS = "5fcy9woa8Di1QHcce65CsV3XKrxdB2pD4HJx5xx82ipM"
@ -30,6 +31,7 @@ object TestConstants {
const val XRP_ACTIVATED_RECIPIENT_ADDRESS = "rN7n7otQDd6FczFgLdSqtcsAUxDkw6fzRH" const val XRP_ACTIVATED_RECIPIENT_ADDRESS = "rN7n7otQDd6FczFgLdSqtcsAUxDkw6fzRH"
const val DOGECOIN_RECIPIENT_ADDRESS = "DJQR3bdhBKcFGMHX2BkMCkrMFApNWNzr6V" const val DOGECOIN_RECIPIENT_ADDRESS = "DJQR3bdhBKcFGMHX2BkMCkrMFApNWNzr6V"
const val DOGECOIN_ADDRESS = "DJ2TaZ5vvp3mBLugUpKjVM3pRBLi4uYaqz" const val DOGECOIN_ADDRESS = "DJ2TaZ5vvp3mBLugUpKjVM3pRBLi4uYaqz"
const val TERRA_RECIPIENT_ADDRESS = "terra148dmp5ccazcwdmrcpvqz5rprnn886kemqen3tj"
const val WAIT_UNTIL_TIMEOUT = 20_000L const val WAIT_UNTIL_TIMEOUT = 20_000L
const val WAIT_UNTIL_TIMEOUT_LONG = 30_000L const val WAIT_UNTIL_TIMEOUT_LONG = 30_000L

View file

@ -54,6 +54,10 @@ fun BaseTestCase.checkNetworkFeeBlock(currentFeeAmount: String, withFeeSelector:
step("Assert select fee icon is displayed") { step("Assert select fee icon is displayed") {
onSendConfirmScreen { selectFeeIcon.assertIsDisplayed() } onSendConfirmScreen { selectFeeIcon.assertIsDisplayed() }
} }
} else {
step("Assert select fee icon is not displayed") {
onSendConfirmScreen { selectFeeIcon.assertIsNotDisplayed() }
}
} }
} }
@ -138,4 +142,29 @@ fun BaseTestCase.checkRecentAddressItem(address: String, description: String?) {
recentAddressItem(recipientAddress = address, description = description).assertIsDisplayed() recentAddressItem(recipientAddress = address, description = description).assertIsDisplayed()
} }
} }
}
fun BaseTestCase.checkCustomFeeTooltip(title: String, tooltip: String) {
step("Click on tooltip icon for '$title'") {
waitForIdle()
onSendSelectNetworkFeeBottomSheet { tooltipIcon(title).performClick() }
}
step("Check '$title' tooltip text") {
onSendSelectNetworkFeeBottomSheet { tooltipText(tooltip).assertIsDisplayed() }
}
step("Click on tooltip icon again to close tooltip") {
onSendSelectNetworkFeeBottomSheet { tooltipIcon(title).performClick() }
}
}
fun BaseTestCase.checkChangesInInputTextField(title: String, newValue: String, addition: String = "") {
step("Click on '$title' input text field") {
onSendSelectNetworkFeeBottomSheet { inputTextFieldValue(title).performClick() }
}
step("Type '$newValue' in '$title' input text field") {
onSendSelectNetworkFeeBottomSheet { inputTextFieldValue(title).performTextReplacement(newValue) }
}
step("Assert '$title' value: '$newValue + $addition'") {
onSendSelectNetworkFeeBottomSheet { inputTextFieldValue(title).assertTextContains(newValue + addition) }
}
} }

View file

@ -95,10 +95,8 @@ class SendAddressPageObject(semanticsProvider: SemanticsNodeInteractionsProvider
hasAnyDescendant(withText(description, substring = true)) hasAnyDescendant(withText(description, substring = true))
} }
if (isMyWallet) { if (isMyWallet) {
hasAnySibling(withText(getResourceString(CoreUiR.string.send_recipient_wallets_title)))
hasAnyDescendant(withText(getResourceString(CoreUiR.string.manage_tokens_network_selector_wallet))) hasAnyDescendant(withText(getResourceString(CoreUiR.string.manage_tokens_network_selector_wallet)))
} else { } else {
hasAnySibling(withText(getResourceString(CoreUiR.string.send_recent_transactions)))
hasAnyDescendant(withTestTag(SendAddressScreenTestTags.RECENT_ADDRESS_TRANSACTION_ICON)) hasAnyDescendant(withTestTag(SendAddressScreenTestTags.RECENT_ADDRESS_TRANSACTION_ICON))
} }
} }
@ -119,8 +117,7 @@ class SendAddressPageObject(semanticsProvider: SemanticsNodeInteractionsProvider
} }
val destinationTagTextFieldHint: KNode = child { val destinationTagTextFieldHint: KNode = child {
hasParent(withTestTag(SendAddressScreenTestTags.DESTINATION_TAG_TEXT_FIELD)) hasTestTag(SendAddressScreenTestTags.DESTINATION_TAG_TEXT_FIELD)
useUnmergedTree = true
} }
val destinationTagBlockText: KNode = child { val destinationTagBlockText: KNode = child {

View file

@ -0,0 +1,102 @@
package com.tangem.screens
import androidx.compose.ui.test.SemanticsNodeInteractionsProvider
import com.tangem.common.BaseTestCase
import com.tangem.core.ui.test.BaseBottomSheetTestTags
import com.tangem.core.ui.test.BaseButtonTestTags
import com.tangem.core.ui.test.SendSelectNetworkFeeBottomSheetTestTags
import com.tangem.wallet.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
class SendSelectNetworkFeeBottomSheetPageObject(semanticsProvider: SemanticsNodeInteractionsProvider) :
ComposeScreen<SendSelectNetworkFeeBottomSheetPageObject>(semanticsProvider = semanticsProvider) {
val title: KNode = child {
hasTestTag(BaseBottomSheetTestTags.TITLE)
hasText(getResourceString(R.string.common_network_fee_title))
useUnmergedTree = true
}
fun regularFeeSelectorItem(title: String): KNode = child {
hasTestTag(SendSelectNetworkFeeBottomSheetTestTags.REGULAR_FEE_ITEM)
hasAnyDescendant(withText(title))
hasAnyDescendant(withTestTag(SendSelectNetworkFeeBottomSheetTestTags.REGULAR_ITEM_ICON))
hasAnyDescendant(withTestTag(SendSelectNetworkFeeBottomSheetTestTags.REGULAR_ITEM_TITLE))
hasAnyDescendant(withTestTag(SendSelectNetworkFeeBottomSheetTestTags.TOKEN_AMOUNT))
hasAnyDescendant(withTestTag(SendSelectNetworkFeeBottomSheetTestTags.FIAT_AMOUNT))
hasAnyDescendant(withTestTag(SendSelectNetworkFeeBottomSheetTestTags.DOT_SIGN))
useUnmergedTree = true
}
val customSelectorItem: KNode = child {
hasTestTag(SendSelectNetworkFeeBottomSheetTestTags.CUSTOM_FEE_ITEM)
hasAnyDescendant(withText(getResourceString(R.string.common_custom)))
hasAnyDescendant(withTestTag(SendSelectNetworkFeeBottomSheetTestTags.CUSTOM_ITEM_ICON))
hasAnyDescendant(withTestTag(SendSelectNetworkFeeBottomSheetTestTags.CUSTOM_ITEM_TITLE))
useUnmergedTree = true
}
fun customInputItem(title: String, hasFiatAmount: Boolean = false): KNode = child {
hasTestTag(SendSelectNetworkFeeBottomSheetTestTags.CUSTOM_INPUT_ITEM)
hasAnyDescendant(withText(title))
hasAnyDescendant(withTestTag(SendSelectNetworkFeeBottomSheetTestTags.CUSTOM_INPUT_ITEM_TITLE))
hasAnyDescendant(withTestTag(SendSelectNetworkFeeBottomSheetTestTags.CUSTOM_INPUT_ITEM_TOOLTIP_ICON))
hasAnyDescendant(withTestTag(SendSelectNetworkFeeBottomSheetTestTags.CUSTOM_INPUT_ITEM_INPUT_TEXT_FIELD))
useUnmergedTree = true
if (hasFiatAmount) {
hasAnyDescendant(withTestTag(SendSelectNetworkFeeBottomSheetTestTags.CUSTOM_INPUT_ITEM_FIAT_AMOUNT))
}
}
val nonceInputItem: KNode = child {
hasTestTag(SendSelectNetworkFeeBottomSheetTestTags.NONCE_INPUT_ITEM)
hasAnyDescendant(withText(getResourceString(R.string.send_nonce)))
hasAnyDescendant(withText(getResourceString(R.string.send_nonce_hint)))
useUnmergedTree = true
}
fun tooltipIcon(title: String): KNode = child {
hasAnySibling(withText(title))
useUnmergedTree = true
}
fun tooltipText(text: String): KNode = child {
hasText(text)
useUnmergedTree = true
}
private fun inputTextField(title: String): KNode = child {
hasTestTag(SendSelectNetworkFeeBottomSheetTestTags.CUSTOM_INPUT_ITEM_INPUT_TEXT_FIELD)
hasAnySibling(withText(title))
useUnmergedTree = true
}
fun inputTextFieldValue(title: String): KNode = inputTextField(title).child {
hasParent(withTestTag(SendSelectNetworkFeeBottomSheetTestTags.CUSTOM_INPUT_ITEM_INPUT_TEXT_FIELD))
useUnmergedTree = true
}
val nonceInputTextField: KNode = child {
hasTestTag(SendSelectNetworkFeeBottomSheetTestTags.NONCE_INPUT_TEXT_FIELD)
useUnmergedTree = true
}
val customInputItemFiatAmount: KNode = child {
hasTestTag(SendSelectNetworkFeeBottomSheetTestTags.CUSTOM_INPUT_ITEM_FIAT_AMOUNT)
useUnmergedTree = true
}
val doneButton: KNode = child {
hasTestTag(BaseButtonTestTags.TEXT)
hasText(getResourceString(R.string.common_done))
useUnmergedTree = true
}
}
internal fun BaseTestCase.onSendSelectNetworkFeeBottomSheet(function: SendSelectNetworkFeeBottomSheetPageObject.() -> Unit) =
onComposeScreen(composeTestRule, function)

View file

@ -2,7 +2,7 @@ package com.tangem.screens
import androidx.compose.ui.test.SemanticsNodeInteractionsProvider import androidx.compose.ui.test.SemanticsNodeInteractionsProvider
import com.tangem.common.BaseTestCase import com.tangem.common.BaseTestCase
import com.tangem.core.ui.test.SelectNetworkFeeBottomSheetTestTags import com.tangem.core.ui.test.SwapSelectNetworkFeeBottomSheetTestTags
import com.tangem.core.ui.test.TopAppBarTestTags import com.tangem.core.ui.test.TopAppBarTestTags
import com.tangem.wallet.R import com.tangem.wallet.R
import io.github.kakaocup.compose.node.element.ComposeScreen import io.github.kakaocup.compose.node.element.ComposeScreen
@ -11,8 +11,8 @@ import io.github.kakaocup.compose.node.element.KNode
import io.github.kakaocup.kakao.common.utilities.getResourceString import io.github.kakaocup.kakao.common.utilities.getResourceString
import androidx.compose.ui.test.hasText as withText import androidx.compose.ui.test.hasText as withText
class SelectNetworkFeePageObject(semanticsProvider: SemanticsNodeInteractionsProvider) : class SwapSelectNetworkFeeBottomSheetPageObject(semanticsProvider: SemanticsNodeInteractionsProvider) :
ComposeScreen<SelectNetworkFeePageObject>(semanticsProvider = semanticsProvider) { ComposeScreen<SwapSelectNetworkFeeBottomSheetPageObject>(semanticsProvider = semanticsProvider) {
val title: KNode = child { val title: KNode = child {
hasTestTag(TopAppBarTestTags.TITLE) hasTestTag(TopAppBarTestTags.TITLE)
@ -21,22 +21,22 @@ class SelectNetworkFeePageObject(semanticsProvider: SemanticsNodeInteractionsPro
} }
val marketSelectorItem: KNode = child { val marketSelectorItem: KNode = child {
hasTestTag(SelectNetworkFeeBottomSheetTestTags.SELECTOR_ITEM) hasTestTag(SwapSelectNetworkFeeBottomSheetTestTags.SELECTOR_ITEM)
hasAnyChild(withText(getResourceString(R.string.common_fee_selector_option_market))) hasAnyChild(withText(getResourceString(R.string.common_fee_selector_option_market)))
useUnmergedTree = true useUnmergedTree = true
} }
val fastSelectorItem: KNode = child { val fastSelectorItem: KNode = child {
hasTestTag(SelectNetworkFeeBottomSheetTestTags.SELECTOR_ITEM) hasTestTag(SwapSelectNetworkFeeBottomSheetTestTags.SELECTOR_ITEM)
hasAnyChild(withText(getResourceString(R.string.common_fee_selector_option_fast))) hasAnyChild(withText(getResourceString(R.string.common_fee_selector_option_fast)))
useUnmergedTree = true useUnmergedTree = true
} }
val readMoreTextBlock: KNode = child { val readMoreTextBlock: KNode = child {
hasTestTag(SelectNetworkFeeBottomSheetTestTags.READ_MORE_TEXT) hasTestTag(SwapSelectNetworkFeeBottomSheetTestTags.READ_MORE_TEXT)
useUnmergedTree = true useUnmergedTree = true
} }
} }
internal fun BaseTestCase.onSelectNetworkFeeBottomSheet(function: SelectNetworkFeePageObject.() -> Unit) = internal fun BaseTestCase.onSwapSelectNetworkFeeBottomSheet(function: SwapSelectNetworkFeeBottomSheetPageObject.() -> Unit) =
onComposeScreen(composeTestRule, function) onComposeScreen(composeTestRule, function)

View file

@ -215,19 +215,19 @@ class SwapTokenTest : BaseTestCase() {
} }
} }
step("Assert 'Select fee' bottom sheet title is displayed") { step("Assert 'Select fee' bottom sheet title is displayed") {
onSelectNetworkFeeBottomSheet { title.assertIsDisplayed() } onSwapSelectNetworkFeeBottomSheet { title.assertIsDisplayed() }
} }
step("Assert 'Market' item is displayed") { step("Assert 'Market' item is displayed") {
onSelectNetworkFeeBottomSheet { marketSelectorItem.assertIsDisplayed() } onSwapSelectNetworkFeeBottomSheet { marketSelectorItem.assertIsDisplayed() }
} }
step("Assert 'Fast' item is displayed") { step("Assert 'Fast' item is displayed") {
onSelectNetworkFeeBottomSheet { fastSelectorItem.assertIsDisplayed() } onSwapSelectNetworkFeeBottomSheet { fastSelectorItem.assertIsDisplayed() }
} }
step("Assert 'Read more' text block is displayed") { step("Assert 'Read more' text block is displayed") {
onSelectNetworkFeeBottomSheet { readMoreTextBlock.assertIsDisplayed() } onSwapSelectNetworkFeeBottomSheet { readMoreTextBlock.assertIsDisplayed() }
} }
step("Click on 'Fast' item") { step("Click on 'Fast' item") {
onSelectNetworkFeeBottomSheet { fastSelectorItem.assertIsDisplayed() } onSwapSelectNetworkFeeBottomSheet { fastSelectorItem.assertIsDisplayed() }
} }
step("Assert 'Network fee' block is displayed") { step("Assert 'Network fee' block is displayed") {
onSwapTokenScreen { onSwapTokenScreen {

View file

@ -84,8 +84,8 @@ class TokenDetailsScreenActionButtonsTest : BaseTestCase() {
step("Assert 'Buy' button is not dimmed") { step("Assert 'Buy' button is not dimmed") {
onTokenDetailsScreen { buyButton().assertContentDescriptionEquals(actionButtonIsNotDimmed) } onTokenDetailsScreen { buyButton().assertContentDescriptionEquals(actionButtonIsNotDimmed) }
} }
step("Assert 'Send' button is dimmed") { step("Assert 'Send' button is not dimmed") {
onTokenDetailsScreen { sendButton().assertContentDescriptionEquals(actionButtonIsDimmed) } onTokenDetailsScreen { sendButton().assertContentDescriptionEquals(actionButtonIsNotDimmed) }
} }
step("Assert 'Swap' button is dimmed") { step("Assert 'Swap' button is dimmed") {
onTokenDetailsScreen { swapButton().assertContentDescriptionEquals(actionButtonIsDimmed) } onTokenDetailsScreen { swapButton().assertContentDescriptionEquals(actionButtonIsDimmed) }

View file

@ -0,0 +1,438 @@
package com.tangem.tests.send.feeScreen
import com.tangem.common.BaseTestCase
import com.tangem.common.constants.TestConstants.BITCOIN_RECIPIENT_ADDRESS
import com.tangem.common.constants.TestConstants.ETHEREUM_RECIPIENT_ADDRESS
import com.tangem.common.constants.TestConstants.POLKADOT_RECIPIENT_ADDRESS
import com.tangem.common.constants.TestConstants.QUOTES_API_SCENARIO
import com.tangem.common.constants.TestConstants.TERRA_RECIPIENT_ADDRESS
import com.tangem.common.constants.TestConstants.USER_TOKENS_API_SCENARIO
import com.tangem.common.extensions.SwipeDirection
import com.tangem.common.extensions.clickWithAssertion
import com.tangem.common.extensions.swipeVertical
import com.tangem.common.utils.resetWireMockScenarioState
import com.tangem.common.utils.setWireMockScenarioState
import com.tangem.core.ui.R
import com.tangem.scenarios.*
import com.tangem.screens.*
import dagger.hilt.android.testing.HiltAndroidTest
import io.github.kakaocup.kakao.common.utilities.getResourceString
import io.qameta.allure.kotlin.AllureId
import io.qameta.allure.kotlin.junit4.DisplayName
import org.junit.Test
@HiltAndroidTest
class SendFeeScreenTest : BaseTestCase() {
@AllureId("4906")
@DisplayName("Send (Fee screen): check fee block for fee in token")
@Test
fun checkFeeBlockForFeeInTokenTest() {
val tokenName = "TerraClassicUSD"
val scenarioName = "Terra"
val tokenAmount = "1"
val feeAmount = "<$0.01"
setupHooks(
additionalAfterSection = {
resetWireMockScenarioState(USER_TOKENS_API_SCENARIO)
resetWireMockScenarioState(QUOTES_API_SCENARIO)
}
).run {
step("Open 'Send' screen") {
openSendScreen(tokenName, scenarioName)
}
step("Type '$tokenAmount' in input text field") {
onSendScreen {
amountInputTextField.performClick()
amountInputTextField.performTextReplacement(tokenAmount)
}
}
step("Click on 'Next' button") {
onSendAddressScreen { nextButton.clickWithAssertion() }
}
step("Type recipient address") {
onSendAddressScreen { addressTextField.performTextReplacement(TERRA_RECIPIENT_ADDRESS) }
}
step("Click on 'Next' button") {
onSendScreen { nextButton.clickWithAssertion() }
}
step("Assert fee block is displayed without fee selector") {
checkNetworkFeeBlock(currentFeeAmount = feeAmount, withFeeSelector = false)
}
}
}
@AllureId("4868")
@DisplayName("Send (Fee screen): check fee block for fixed fee")
@Test
fun checkFeeBlockForFixedFeeTest() {
val tokenName = "Polkadot"
val tokenAmount = "1"
val feeAmount = "$0.05"
setupHooks(
additionalAfterSection = {
resetWireMockScenarioState(USER_TOKENS_API_SCENARIO)
resetWireMockScenarioState(QUOTES_API_SCENARIO)
}
).run {
step("Open 'Send' screen") {
openSendScreen(tokenName)
}
step("Type '$tokenAmount' in input text field") {
onSendScreen {
amountInputTextField.performClick()
amountInputTextField.performTextReplacement(tokenAmount)
}
}
step("Click on 'Next' button") {
onSendAddressScreen { nextButton.clickWithAssertion() }
}
step("Type recipient address") {
onSendAddressScreen { addressTextField.performTextReplacement(POLKADOT_RECIPIENT_ADDRESS) }
}
step("Click on 'Next' button") {
onSendScreen { nextButton.clickWithAssertion() }
}
step("Assert fee block is displayed without fee selector") {
checkNetworkFeeBlock(currentFeeAmount = feeAmount, withFeeSelector = false)
}
step("Click on 'Fee selector' block") {
onSendConfirmScreen { feeSelectorBlock.performClick() }
}
step("Assert 'Fee selector' bottom sheet is not displayed") {
onSwapSelectNetworkFeeBottomSheet { title.assertIsNotDisplayed() }
}
}
}
@AllureId("4869")
@DisplayName("Send (Fee screen): check network fee bottom sheet for EVM networks")
@Test
fun checkNetworkFeeBottomSheetForEvmTest() {
val tokenName = "Ethereum"
val tokenAmount = "0.1"
val feeAmount = "~$1.06"
val fiatFeeAmount = "$1.09"
val newFeeAmount = "~$1.09"
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 feeUpToTooltip = getResourceString(R.string.send_custom_amount_fee_footer)
val feeUpToValue = "0.00042 ETH"
val newFeeUpToValue = "0.00043"
val maxFee = getResourceString(R.string.send_custom_evm_max_fee)
val maxFeeTooltip = getResourceString(R.string.send_custom_evm_max_fee_footer)
val maxFeeValue = "20 GWEI"
val newMaxFeeValue = "21"
val priorityFee = getResourceString(R.string.send_custom_evm_priority_fee)
val priorityFeeTooltip = getResourceString(R.string.send_custom_evm_priority_fee_footer)
val priorityFeeValue = "2 GWEI"
val newPriorityFeeValue = "3"
val gasLimit = getResourceString(R.string.send_gas_limit)
val gasLimitTooltip = getResourceString(R.string.send_gas_limit_footer)
val gasLimitValue = "21,000 "
val newGasLimitValue = "22"
val nonce = getResourceString(R.string.send_nonce)
val nonceTooltip = getResourceString(R.string.send_nonce_footer)
val nonceValue = "1"
setupHooks(
additionalAfterSection = {
resetWireMockScenarioState(USER_TOKENS_API_SCENARIO)
resetWireMockScenarioState(QUOTES_API_SCENARIO)
}
).run {
step("Open 'Send' screen") {
openSendScreen(tokenName)
}
step("Type '$tokenAmount' in input text field") {
onSendScreen {
amountInputTextField.performClick()
amountInputTextField.performTextReplacement(tokenAmount)
}
}
step("Click on 'Next' button") {
onSendAddressScreen { nextButton.clickWithAssertion() }
}
step("Type recipient address") {
onSendAddressScreen { addressTextField.performTextReplacement(ETHEREUM_RECIPIENT_ADDRESS) }
}
step("Click on 'Next' button") {
onSendScreen { nextButton.clickWithAssertion() }
}
step("Assert fee block is displayed with fee selector") {
checkNetworkFeeBlock(currentFeeAmount = feeAmount, withFeeSelector = true)
}
step("Click on fee selector icon") {
onSendConfirmScreen { feeSelectorIcon.performClick() }
}
step("Assert 'Fee selector' bottom sheet title is displayed") {
onSendSelectNetworkFeeBottomSheet { title.assertIsDisplayed() }
}
step("Assert '$fastSelectorItem' selector item is displayed") {
onSendSelectNetworkFeeBottomSheet { regularFeeSelectorItem(fastSelectorItem).assertIsDisplayed() }
}
step("Assert '$slowSelectorItem' selector item is displayed") {
onSendSelectNetworkFeeBottomSheet { regularFeeSelectorItem(slowSelectorItem).assertIsDisplayed() }
}
step("Assert '$marketSelectorItem' selector item is displayed") {
onSendSelectNetworkFeeBottomSheet { regularFeeSelectorItem(marketSelectorItem).assertIsDisplayed() }
}
step("Assert 'Custom' selector item is displayed") {
onSendSelectNetworkFeeBottomSheet { customSelectorItem.assertIsDisplayed() }
}
step("Click on 'Custom' selector item") {
onSendSelectNetworkFeeBottomSheet { customSelectorItem.performClick() }
}
step("Assert '$feeUpTo' input item is displayed") {
onSendSelectNetworkFeeBottomSheet {
customInputItem(title = feeUpTo, hasFiatAmount = true).assertIsDisplayed()
}
}
step("Assert '$maxFee' input item is displayed") {
onSendSelectNetworkFeeBottomSheet { customInputItem(maxFee).assertIsDisplayed() }
}
step("Assert '$priorityFee' input item is displayed") {
onSendSelectNetworkFeeBottomSheet { customInputItem(priorityFee).assertIsDisplayed() }
}
step("Assert '$gasLimit' input item is displayed") {
onSendSelectNetworkFeeBottomSheet { customInputItem(gasLimit).assertIsDisplayed() }
}
step("Swipe up") {
swipeVertical(SwipeDirection.UP)
}
step("Assert '$nonce' input item is displayed") {
onSendSelectNetworkFeeBottomSheet { nonceInputItem.assertIsDisplayed() }
}
step("Check '$feeUpTo' tooltip") {
checkCustomFeeTooltip(title = feeUpTo, tooltip = feeUpToTooltip)
}
step("Check '$maxFee' tooltip") {
checkCustomFeeTooltip(title = maxFee, tooltip = maxFeeTooltip)
}
step("Check '$priorityFee' tooltip") {
checkCustomFeeTooltip(title = priorityFee, tooltip = priorityFeeTooltip)
}
step("Check '$gasLimit' tooltip") {
checkCustomFeeTooltip(title = gasLimit, tooltip = gasLimitTooltip)
}
step("Check '$nonce' tooltip") {
checkCustomFeeTooltip(title = nonce, tooltip = nonceTooltip)
}
step("Assert '$feeUpTo' value: '$feeUpToValue'") {
onSendSelectNetworkFeeBottomSheet { inputTextFieldValue(feeUpTo).assertTextContains(feeUpToValue) }
}
step("Assert '$maxFee' value: '$maxFeeValue'") {
onSendSelectNetworkFeeBottomSheet { inputTextFieldValue(maxFee).assertTextContains(maxFeeValue) }
}
step("Assert '$priorityFee' value: '$priorityFeeValue'") {
onSendSelectNetworkFeeBottomSheet { inputTextFieldValue(priorityFee).assertTextContains(priorityFeeValue) }
}
step("Assert '$gasLimit' value: '$gasLimitValue'") {
onSendSelectNetworkFeeBottomSheet { inputTextFieldValue(gasLimit).assertTextContains(gasLimitValue) }
}
step("Check changes in '$maxFee' input text field") {
checkChangesInInputTextField(title = maxFee, newValue = newMaxFeeValue, addition = " GWEI")
}
step("Check changes in '$priorityFee' input text field") {
checkChangesInInputTextField(title = priorityFee, newValue = newPriorityFeeValue, addition = " GWEI")
}
step("Check changes in '$gasLimit' input text field") {
checkChangesInInputTextField(title = gasLimit, newValue = newGasLimitValue, addition = " ")
}
step("Type '$nonceValue' in '$nonce' text field") {
onSendSelectNetworkFeeBottomSheet {
nonceInputTextField.performClick()
nonceInputTextField.performTextReplacement(nonceValue)
}
}
step("Assert '$nonce' value: '$nonceValue'") {
onSendSelectNetworkFeeBottomSheet { nonceInputTextField.assertTextContains(nonceValue) }
}
step("Check changes in '$feeUpTo' input text field") {
checkChangesInInputTextField(title = feeUpTo, newValue = newFeeUpToValue, addition = " ETH")
}
step("Assert new fiat fee amount: '$fiatFeeAmount'") {
onSendSelectNetworkFeeBottomSheet { customInputItemFiatAmount.assertTextContains(fiatFeeAmount) }
}
step("Click on 'Done' button") {
onSendSelectNetworkFeeBottomSheet { doneButton.performClick() }
}
step("Assert fee block is displayed with new fee amount: '$newFeeAmount'") {
checkNetworkFeeBlock(currentFeeAmount = newFeeAmount, withFeeSelector = true)
}
}
}
@AllureId("4870")
@DisplayName("Send (Fee screen): check network fee bottom sheet for Bitcoin")
@Test
fun checkNetworkFeeBottomSheetForBitcoinTest() {
val tokenName = "Bitcoin"
val tokenAmount = "0.00000001"
val feeAmount = "$2.86"
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 newFeeUpToValue = "0.0000022 BTC"
val satoshi = getResourceString(R.string.send_satoshi_per_byte_title)
val satoshiValue = "2"
val decimalNumber = "2.11"
val newSatoshiValue = "1"
val bitcoinUtxoScenarioName = "bitcoin_utxo"
val bitcoinUtxoScenarioState = "Balance"
val feeScenarioName = "bitcoin_estimate_smart_fee"
val feeScenarioState = "Started"
setupHooks(
additionalAfterSection = {
resetWireMockScenarioState(USER_TOKENS_API_SCENARIO)
resetWireMockScenarioState(bitcoinUtxoScenarioName)
}
).run {
step("Set WireMock scenario: '$bitcoinUtxoScenarioName' to state: '$bitcoinUtxoScenarioState'") {
setWireMockScenarioState(bitcoinUtxoScenarioName, bitcoinUtxoScenarioState)
}
step("Set WireMock scenario: '$feeScenarioName' to state: '$feeScenarioState'") {
setWireMockScenarioState(feeScenarioName, feeScenarioState)
}
step("Open 'Main' screen") {
openMainScreen()
}
step("Synchronize addresses") {
synchronizeAddresses()
}
step("Open 'Send address' screen") {
openSendAddressScreen(tokenName, tokenAmount)
}
step("Type recipient address") {
onSendAddressScreen { addressTextField.performTextReplacement(BITCOIN_RECIPIENT_ADDRESS) }
}
step("Click on 'Next' button") {
onSendScreen { nextButton.clickWithAssertion() }
}
step("Assert fee block is displayed with fee selector") {
checkNetworkFeeBlock(currentFeeAmount = feeAmount, withFeeSelector = true)
}
step("Click on fee selector icon") {
onSendConfirmScreen { feeSelectorIcon.performClick() }
}
step("Assert 'Fee selector' bottom sheet title is displayed") {
onSendSelectNetworkFeeBottomSheet { title.assertIsDisplayed() }
}
step("Assert '$fastSelectorItem' selector item is displayed") {
onSendSelectNetworkFeeBottomSheet { regularFeeSelectorItem(fastSelectorItem).assertIsDisplayed() }
}
step("Assert '$slowSelectorItem' selector item is displayed") {
onSendSelectNetworkFeeBottomSheet { regularFeeSelectorItem(slowSelectorItem).assertIsDisplayed() }
}
step("Assert '$marketSelectorItem' selector item is displayed") {
onSendSelectNetworkFeeBottomSheet { regularFeeSelectorItem(marketSelectorItem).assertIsDisplayed() }
}
step("Assert 'Custom' selector item is displayed") {
onSendSelectNetworkFeeBottomSheet { customSelectorItem.assertIsDisplayed() }
}
step("Click on 'Custom' selector item") {
onSendSelectNetworkFeeBottomSheet { customSelectorItem.performClick() }
}
step("Assert '$feeUpTo' input item is displayed") {
onSendSelectNetworkFeeBottomSheet {
customInputItem(title = feeUpTo, hasFiatAmount = true).assertIsDisplayed()
}
}
step("Assert '$feeUpTo' value: '$feeUpToValue'") {
onSendSelectNetworkFeeBottomSheet { inputTextFieldValue(feeUpTo).assertTextContains(feeUpToValue) }
}
step("Assert '$satoshi' input item is displayed") {
onSendSelectNetworkFeeBottomSheet { customInputItem(satoshi).assertIsDisplayed() }
}
step("Click on '$satoshi' input text field") {
onSendSelectNetworkFeeBottomSheet { inputTextFieldValue(satoshi).performClick() }
}
step("Type '$decimalNumber' in '$satoshi' input text field") {
onSendSelectNetworkFeeBottomSheet { inputTextFieldValue(satoshi).performTextReplacement(decimalNumber) }
}
step("Assert '$satoshi' value: '$satoshiValue +  '") {
onSendSelectNetworkFeeBottomSheet { inputTextFieldValue(satoshi).assertTextContains("$satoshiValue ") }
}
step("Check changes in '$satoshi' input text field") {
checkChangesInInputTextField(title = satoshi, newValue = newSatoshiValue, addition = " ")
}
step("Assert new fiat fee amount: '$fiatFeeAmount'") {
onSendSelectNetworkFeeBottomSheet { customInputItemFiatAmount.assertTextContains(fiatFeeAmount) }
}
step("Assert '$feeUpTo' value: '$newFeeUpToValue'") {
onSendSelectNetworkFeeBottomSheet { inputTextFieldValue(feeUpTo).assertTextContains(newFeeUpToValue) }
}
step("Click on 'Done' button") {
onSendSelectNetworkFeeBottomSheet { doneButton.performClick() }
}
step("Assert fee block is displayed with new fee amount: '$fiatFeeAmount'") {
checkNetworkFeeBlock(currentFeeAmount = fiatFeeAmount, withFeeSelector = true)
}
}
}
@AllureId("4871")
@DisplayName("Send (Fee screen): check network fee bottom sheet networks with fee in token")
@Test
fun checkNetworkFeeBottomSheetForVeThorTest() {
val tokenName = "VeThor"
val mockState = "Vechain"
val tokenAmount = "0.1"
val feeAmount = "<$0.01"
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)
setupHooks(
additionalAfterSection = {
resetWireMockScenarioState(USER_TOKENS_API_SCENARIO)
resetWireMockScenarioState(QUOTES_API_SCENARIO)
}
).run {
step("Open 'Send' screen") {
openSendScreen(tokenName = tokenName, mockState = mockState)
}
step("Type '$tokenAmount' in input text field") {
onSendScreen {
amountInputTextField.performClick()
amountInputTextField.performTextReplacement(tokenAmount)
}
}
step("Click on 'Next' button") {
onSendAddressScreen { nextButton.clickWithAssertion() }
}
step("Type recipient address") {
onSendAddressScreen { addressTextField.performTextReplacement(ETHEREUM_RECIPIENT_ADDRESS) }
}
step("Click on 'Next' button") {
onSendScreen { nextButton.clickWithAssertion() }
}
step("Assert fee block is displayed with fee selector") {
checkNetworkFeeBlock(currentFeeAmount = feeAmount, withFeeSelector = true)
}
step("Click on fee selector icon") {
onSendConfirmScreen { feeSelectorIcon.performClick() }
}
step("Assert 'Fee selector' bottom sheet title is displayed") {
onSendSelectNetworkFeeBottomSheet { title.assertIsDisplayed() }
}
step("Assert '$fastSelectorItem' selector item is displayed") {
onSendSelectNetworkFeeBottomSheet { regularFeeSelectorItem(fastSelectorItem).assertIsDisplayed() }
}
step("Assert '$slowSelectorItem' selector item is displayed") {
onSendSelectNetworkFeeBottomSheet { regularFeeSelectorItem(slowSelectorItem).assertIsDisplayed() }
}
step("Assert '$marketSelectorItem' selector item is displayed") {
onSendSelectNetworkFeeBottomSheet { regularFeeSelectorItem(marketSelectorItem).assertIsDisplayed() }
}
}
}
}

View file

@ -94,12 +94,12 @@ internal object StakingDomainModule {
@Singleton @Singleton
fun provideFetchStakingOptionsUseCase( fun provideFetchStakingOptionsUseCase(
stakeKitRepository: StakeKitRepository, stakeKitRepository: StakeKitRepository,
p2pRepository: P2PEthPoolRepository, p2pEthPoolRepository: P2PEthPoolRepository,
stakingErrorResolver: StakingErrorResolver, stakingErrorResolver: StakingErrorResolver,
): FetchStakingOptionsUseCase { ): FetchStakingOptionsUseCase {
return FetchStakingOptionsUseCase( return FetchStakingOptionsUseCase(
stakeKitRepository = stakeKitRepository, stakeKitRepository = stakeKitRepository,
p2pRepository = p2pRepository, p2pEthPoolRepository = p2pEthPoolRepository,
stakingErrorResolver = stakingErrorResolver, stakingErrorResolver = stakingErrorResolver,
) )
} }

View file

@ -141,6 +141,14 @@ object WalletMockContent : MockContent {
publicKey = byteArrayOf(2, -40, 115, -15, 80, 104, 100, -29, 80, 29, 112, -35, -54, 64, -98, 45, 115, 108, 93, 113, -71, 89, 17, 72, 98, 21, 126, 82, -50, 50, -12, -87, -62), publicKey = byteArrayOf(2, -40, 115, -15, 80, 104, 100, -29, 80, 29, 112, -35, -54, 64, -98, 45, 115, 108, 93, 113, -71, 89, 17, 72, 98, 21, 126, 82, -50, 50, -12, -87, -62),
chainCode = byteArrayOf(-27, 56, 57, 54, 27, -40, 65, 109, 119, -54, -94, 88, -29, -115, -45, -112, -31, 57, 53, 56, 118, 87, 34, -16, -64, -45, 105, 77, 91, -106, -92, 41), chainCode = byteArrayOf(-27, 56, 57, 54, 27, -40, 65, 109, 119, -54, -94, 88, -29, -115, -45, -112, -31, 57, 53, 56, 118, 87, 34, -16, -64, -45, 105, 77, 91, -106, -92, 41),
), ),
DerivationPath("m/44'/330'/0'/0/0") to ExtendedPublicKey(
publicKey = byteArrayOf(2, -40, 115, -15, 80, 104, 100, -29, 80, 29, 112, -35, -54, 64, -98, 45, 115, 108, 93, 113, -71, 89, 17, 72, 98, 21, 126, 82, -50, 50, -12, -87, -62),
chainCode = byteArrayOf(-27, 56, 57, 54, 27, -40, 65, 109, 119, -54, -94, 88, -29, -115, -45, -112, -31, 57, 53, 56, 118, 87, 34, -16, -64, -45, 105, 77, 91, -106, -92, 41),
),
DerivationPath("m/44'/818'/0'/0/0") to ExtendedPublicKey(
publicKey = byteArrayOf(2, -40, 115, -15, 80, 104, 100, -29, 80, 29, 112, -35, -54, 64, -98, 45, 115, 108, 93, 113, -71, 89, 17, 72, 98, 21, 126, 82, -50, 50, -12, -87, -62),
chainCode = byteArrayOf(-27, 56, 57, 54, 27, -40, 65, 109, 119, -54, -94, 88, -29, -115, -45, -112, -31, 57, 53, 56, 118, 87, 34, -16, -64, -45, 105, 77, 91, -106, -92, 41),
),
), ),
extendedPublicKey = ExtendedPublicKey( extendedPublicKey = ExtendedPublicKey(
publicKey = byteArrayOf(2, -109, 28, -27, -124, -58, -97, -61, 43, -84, 90, -9, -5, 4, 90, 17, 112, -125, -108, 44, 19, -79, -60, -23, 34, -20, -20, 61, 84, 113, 120, -90, -5), publicKey = byteArrayOf(2, -109, 28, -27, -124, -58, -97, -61, 43, -84, 90, -9, -5, 4, 90, 17, 112, -125, -108, 44, 19, -79, -60, -23, 34, -20, -20, 61, 84, 113, 120, -90, -5),
@ -261,6 +269,20 @@ object WalletMockContent : MockContent {
parentFingerprint = byteArrayOf(0, 0, 0, 0), parentFingerprint = byteArrayOf(0, 0, 0, 0),
childNumber = 0, childNumber = 0,
), ),
DerivationPath("m/44'/330'/0'/0/0") to ExtendedPublicKey( // Terra
publicKey = byteArrayOf(2, -40, 115, -15, 80, 104, 100, -29, 80, 29, 112, -35, -54, 64, -98, 45, 115, 108, 93, 113, -71, 89, 17, 72, 98, 21, 126, 82, -50, 50, -12, -87, -62),
chainCode = byteArrayOf(-27, 56, 57, 54, 27, -40, 65, 109, 119, -54, -94, 88, -29, -115, -45, -112, -31, 57, 53, 56, 118, 87, 34, -16, -64, -45, 105, 77, 91, -106, -92, 41),
depth = 0,
parentFingerprint = byteArrayOf(0, 0, 0, 0),
childNumber = 0,
),
DerivationPath("m/44'/818'/0'/0/0") to ExtendedPublicKey( // Vechain
publicKey = byteArrayOf(2, -40, 115, -15, 80, 104, 100, -29, 80, 29, 112, -35, -54, 64, -98, 45, 115, 108, 93, 113, -71, 89, 17, 72, 98, 21, 126, 82, -50, 50, -12, -87, -62),
chainCode = byteArrayOf(-27, 56, 57, 54, 27, -40, 65, 109, 119, -54, -94, 88, -29, -115, -45, -112, -31, 57, 53, 56, 118, 87, 34, -16, -64, -45, 105, 77, 91, -106, -92, 41),
depth = 0,
parentFingerprint = byteArrayOf(0, 0, 0, 0),
childNumber = 0,
),
), ),
), ),
ByteArrayKey( ByteArrayKey(
@ -318,6 +340,20 @@ object WalletMockContent : MockContent {
parentFingerprint = byteArrayOf(0, 0, 0, 0), parentFingerprint = byteArrayOf(0, 0, 0, 0),
childNumber = 0, childNumber = 0,
), ),
DerivationPath("m/44'/330'/0'/0/0") to ExtendedPublicKey( // Terra
publicKey = byteArrayOf(2, 34, 6, 119, -106, 5, -119, 111, -22, 8, 23, -108, -72, -56, 6, 77, -17, -61, -101, -85, 16, 28, 18, 3, -3, -89, -81, -108, 48, -7, -86, -82),
chainCode = byteArrayOf(-75, 55, 107, -106, -37, -81, -15, 72, -102, 94, 55, -39, 9, -112, 1, 90, -50, 103, 53, 120, -92, -36, -85, -39, -65, 1, 88, 46, 92, 104, -13, -109),
depth = 0,
parentFingerprint = byteArrayOf(0, 0, 0, 0),
childNumber = 0,
),
DerivationPath("m/44'/818'/0'/0/0") to ExtendedPublicKey( // Vechain
publicKey = byteArrayOf(2, 34, 6, 119, -106, 5, -119, 111, -22, 8, 23, -108, -72, -56, 6, 77, -17, -61, -101, -85, 16, 28, 18, 3, -3, -89, -81, -108, 48, -7, -86, -82),
chainCode = byteArrayOf(-75, 55, 107, -106, -37, -81, -15, 72, -102, 94, 55, -39, 9, -112, 1, 90, -50, 103, 53, 120, -92, -36, -85, -39, -65, 1, 88, 46, 92, 104, -13, -109),
depth = 0,
parentFingerprint = byteArrayOf(0, 0, 0, 0),
childNumber = 0,
),
), ),
), ),
@ -371,6 +407,13 @@ object WalletMockContent : MockContent {
parentFingerprint = byteArrayOf(0, 0, 0, 0), parentFingerprint = byteArrayOf(0, 0, 0, 0),
childNumber = 0, childNumber = 0,
), ),
DerivationPath("m/44'/818'/0'/0/0") to ExtendedPublicKey( // Vechain
publicKey = byteArrayOf(2, -40, 115, -15, 80, 104, 100, -29, 80, 29, 112, -35, -54, 64, -98, 45, 115, 108, 93, 113, -71, 89, 17, 72, 98, 21, 126, 82, -50, 50, -12, -87, -62),
chainCode = byteArrayOf(-27, 56, 57, 54, 27, -40, 65, 109, 119, -54, -94, 88, -29, -115, -45, -112, -31, 57, 53, 56, 118, 87, 34, -16, -64, -45, 105, 77, 91, -106, -92, 41),
depth = 0,
parentFingerprint = byteArrayOf(0, 0, 0, 0),
childNumber = 0,
),
), ),
), ),
ByteArrayKey( ByteArrayKey(

View file

@ -18,6 +18,7 @@ import com.tangem.domain.card.repository.CardSdkConfigRepository
import com.tangem.domain.models.scan.CardDTO import com.tangem.domain.models.scan.CardDTO
import com.tangem.domain.models.scan.ScanResponse import com.tangem.domain.models.scan.ScanResponse
import com.tangem.domain.models.wallet.requireColdWallet import com.tangem.domain.models.wallet.requireColdWallet
import com.tangem.domain.pay.repository.OnboardingRepository
import com.tangem.domain.settings.repositories.SettingsRepository import com.tangem.domain.settings.repositories.SettingsRepository
import com.tangem.domain.wallets.builder.UserWalletIdBuilder import com.tangem.domain.wallets.builder.UserWalletIdBuilder
import com.tangem.domain.wallets.usecase.GetUserWalletUseCase import com.tangem.domain.wallets.usecase.GetUserWalletUseCase
@ -54,6 +55,7 @@ internal class CardSettingsModel @Inject constructor(
private val getUserWalletUseCase: GetUserWalletUseCase, private val getUserWalletUseCase: GetUserWalletUseCase,
private val cardSdkConfigRepository: CardSdkConfigRepository, private val cardSdkConfigRepository: CardSdkConfigRepository,
private val settingsRepository: SettingsRepository, private val settingsRepository: SettingsRepository,
private val onboardingRepository: OnboardingRepository,
) : Model() { ) : Model() {
private val params = paramsContainer.require<CardSettingsComponent.Params>() private val params = paramsContainer.require<CardSettingsComponent.Params>()
@ -218,15 +220,19 @@ internal class CardSettingsModel @Inject constructor(
} else { } else {
val card = scanResponse.card val card = scanResponse.card
store.dispatchNavigationAction { modelScope.launch {
push( val hasTangemPay = onboardingRepository.checkCustomerWallet(userWalletId).getOrNull() == true
route = AppRoute.ResetToFactory( store.dispatchNavigationAction {
userWalletId = userWalletId, push(
cardId = card.cardId, route = AppRoute.ResetToFactory(
isActiveBackupStatus = card.backupStatus?.isActive == true, userWalletId = userWalletId,
backupCardsCount = scanResponse.getBackupCardsCount() ?: 0, cardId = card.cardId,
), isActiveBackupStatus = card.backupStatus?.isActive == true,
) backupCardsCount = scanResponse.getBackupCardsCount() ?: 0,
hasTangemPay = hasTangemPay,
),
)
}
} }
} }
} }

View file

@ -22,6 +22,7 @@ import com.tangem.core.ui.extensions.resolveReference
import com.tangem.tap.features.details.ui.common.DetailsMainButton import com.tangem.tap.features.details.ui.common.DetailsMainButton
import com.tangem.tap.features.details.ui.common.SettingsScreensScaffold import com.tangem.tap.features.details.ui.common.SettingsScreensScaffold
import com.tangem.wallet.R import com.tangem.wallet.R
import kotlinx.collections.immutable.persistentListOf
import com.tangem.tap.features.details.ui.resetcard.ResetCardScreenState.Dialog as ResetCardDialog import com.tangem.tap.features.details.ui.resetcard.ResetCardScreenState.Dialog as ResetCardDialog
@Composable @Composable
@ -114,22 +115,11 @@ private fun Description(text: TextReference) {
@Composable @Composable
private fun Conditions(state: ResetCardScreenState) { private fun Conditions(state: ResetCardScreenState) {
state.warningsToShow.forEach { warning -> state.warningsToShow.forEach { warning ->
when (warning) { ConditionCheckBox(
ResetCardScreenState.WarningsToReset.LOST_WALLET_ACCESS -> { checkedState = warning.isChecked,
ConditionCheckBox( onCheckedChange = { state.onToggleWarning(warning.type) },
checkedState = state.isAcceptCondition1Checked, description = warning.description,
onCheckedChange = state.onAcceptCondition1ToggleClick, )
description = TextReference.Res(R.string.reset_card_to_factory_condition_1),
)
}
ResetCardScreenState.WarningsToReset.LOST_PASSWORD_RESTORE -> {
ConditionCheckBox(
checkedState = state.isAcceptCondition2Checked,
onCheckedChange = state.onAcceptCondition2ToggleClick,
description = TextReference.Res(R.string.reset_card_to_factory_condition_2),
)
}
}
} }
} }
@ -239,14 +229,16 @@ private fun ResetCardScreenSample(modifier: Modifier = Modifier) {
ResetCardScreen( ResetCardScreen(
state = ResetCardScreenState( state = ResetCardScreenState(
isResetButtonEnabled = true, isResetButtonEnabled = true,
isResetPasswordButtonShown = false, warningsToShow = persistentListOf(
warningsToShow = listOf(ResetCardScreenState.WarningsToReset.LOST_WALLET_ACCESS), ResetCardScreenState.WarningUM(
isChecked = false,
type = ResetCardScreenState.WarningType.LOST_WALLET_ACCESS,
description = TextReference.Res(id = R.string.reset_card_to_factory_condition_1),
),
),
descriptionText = TextReference.Res(R.string.reset_card_with_backup_to_factory_message), descriptionText = TextReference.Res(R.string.reset_card_with_backup_to_factory_message),
isAcceptCondition1Checked = false,
isAcceptCondition2Checked = false,
onAcceptCondition1ToggleClick = {},
onAcceptCondition2ToggleClick = {},
onResetButtonClick = {}, onResetButtonClick = {},
onToggleWarning = {},
dialog = null, dialog = null,
), ),
onBackClick = {}, onBackClick = {},

View file

@ -3,16 +3,13 @@ package com.tangem.tap.features.details.ui.resetcard
import androidx.annotation.StringRes import androidx.annotation.StringRes
import com.tangem.core.ui.extensions.TextReference import com.tangem.core.ui.extensions.TextReference
import com.tangem.wallet.R import com.tangem.wallet.R
import kotlinx.collections.immutable.ImmutableList
internal data class ResetCardScreenState( internal data class ResetCardScreenState(
val isResetButtonEnabled: Boolean, val isResetButtonEnabled: Boolean,
val descriptionText: TextReference, val descriptionText: TextReference,
val warningsToShow: List<WarningsToReset>, val warningsToShow: ImmutableList<WarningUM>,
val isResetPasswordButtonShown: Boolean, val onToggleWarning: (WarningType) -> Unit,
val isAcceptCondition1Checked: Boolean,
val isAcceptCondition2Checked: Boolean,
val onAcceptCondition1ToggleClick: (Boolean) -> Unit,
val onAcceptCondition2ToggleClick: (Boolean) -> Unit,
val onResetButtonClick: () -> Unit, val onResetButtonClick: () -> Unit,
val dialog: Dialog?, val dialog: Dialog?,
) { ) {
@ -58,7 +55,13 @@ internal data class ResetCardScreenState(
} }
} }
internal enum class WarningsToReset { internal data class WarningUM(
LOST_WALLET_ACCESS, LOST_PASSWORD_RESTORE val isChecked: Boolean,
val type: WarningType,
val description: TextReference,
)
internal enum class WarningType {
LOST_WALLET_ACCESS, LOST_PASSWORD_RESTORE, LOST_TANGEM_PAY
} }
} }

View file

@ -11,6 +11,7 @@ interface ResetCardComponent : ComposableContentComponent {
val cardId: String, val cardId: String,
val isActiveBackupStatus: Boolean, val isActiveBackupStatus: Boolean,
val backupCardsCount: Int, val backupCardsCount: Int,
val hasTangemPay: Boolean,
) )
interface Factory : ComponentFactory<Params, ResetCardComponent> interface Factory : ComponentFactory<Params, ResetCardComponent>

View file

@ -8,6 +8,7 @@ import com.tangem.core.analytics.api.AnalyticsEventHandler
import com.tangem.core.decompose.di.ModelScoped import com.tangem.core.decompose.di.ModelScoped
import com.tangem.core.decompose.model.Model import com.tangem.core.decompose.model.Model
import com.tangem.core.decompose.model.ParamsContainer import com.tangem.core.decompose.model.ParamsContainer
import com.tangem.core.ui.extensions.TextReference
import com.tangem.domain.card.DeleteSavedAccessCodesUseCase import com.tangem.domain.card.DeleteSavedAccessCodesUseCase
import com.tangem.domain.card.ResetCardUseCase import com.tangem.domain.card.ResetCardUseCase
import com.tangem.domain.card.ResetCardUserCodeParams import com.tangem.domain.card.ResetCardUserCodeParams
@ -30,6 +31,9 @@ import com.tangem.tap.features.details.ui.resetcard.api.ResetCardComponent
import com.tangem.tap.store import com.tangem.tap.store
import com.tangem.utils.coroutines.CoroutineDispatcherProvider import com.tangem.utils.coroutines.CoroutineDispatcherProvider
import com.tangem.utils.extensions.DELAY_SDK_DIALOG_CLOSE import com.tangem.utils.extensions.DELAY_SDK_DIALOG_CLOSE
import com.tangem.wallet.R
import kotlinx.collections.immutable.ImmutableList
import kotlinx.collections.immutable.toImmutableList
import kotlinx.coroutines.delay import kotlinx.coroutines.delay
import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.update import kotlinx.coroutines.flow.update
@ -82,33 +86,22 @@ internal class ResetCardModel @Inject constructor(
// TODO: move logic to separate domain entity // TODO: move logic to separate domain entity
private var resetBackupCardCount = 0 private var resetBackupCardCount = 0
private var warningsMap = emptyMap<ResetCardScreenState.WarningType, ResetCardScreenState.WarningUM>()
val screenState: MutableStateFlow<ResetCardScreenState> = MutableStateFlow( val screenState: MutableStateFlow<ResetCardScreenState> = MutableStateFlow(
value = getInitialState(), value = getInitialState(),
) )
private fun getInitialState(): ResetCardScreenState { private fun getInitialState(): ResetCardScreenState {
val shouldShowResetPasswordButton = shouldShowResetPasswordButton()
val warningsToShow = buildList {
add(ResetCardScreenState.WarningsToReset.LOST_WALLET_ACCESS)
if (shouldShowResetPasswordButton) {
add(ResetCardScreenState.WarningsToReset.LOST_PASSWORD_RESTORE)
}
}
return ResetCardScreenState( return ResetCardScreenState(
isResetButtonEnabled = false, isResetButtonEnabled = false,
descriptionText = getResetToFactoryDescription( descriptionText = getResetToFactoryDescription(
isActiveBackupStatus = isActiveBackupPrimaryCard, isActiveBackupStatus = isActiveBackupPrimaryCard,
typesResolver = currentCardTypesResolver, typesResolver = currentCardTypesResolver,
), ),
warningsToShow = warningsToShow, warningsToShow = buildInitialItems(),
isResetPasswordButtonShown = shouldShowResetPasswordButton,
isAcceptCondition1Checked = false,
isAcceptCondition2Checked = false,
onAcceptCondition1ToggleClick = ::toggleFirstCondition,
onAcceptCondition2ToggleClick = ::toggleSecondCondition,
onResetButtonClick = { showDialog(ResetCardDialog.StartResetDialog) }, onResetButtonClick = { showDialog(ResetCardDialog.StartResetDialog) },
onToggleWarning = ::toggleCondition,
dialog = null, dialog = null,
) )
} }
@ -119,28 +112,46 @@ internal class ResetCardModel @Inject constructor(
return isTangemWallet && isActiveBackupPrimaryCard return isTangemWallet && isActiveBackupPrimaryCard
} }
private fun toggleFirstCondition(isAccepted: Boolean) { private fun buildInitialItems(): ImmutableList<ResetCardScreenState.WarningUM> {
screenState.update { prevState -> val shouldShowResetPasswordButton = shouldShowResetPasswordButton()
val isResetButtonEnabled = if (prevState.isResetPasswordButtonShown) { val shouldShowResetTangemPayButton = params.hasTangemPay
isAccepted && prevState.isAcceptCondition2Checked
} else {
isAccepted
}
prevState.copy( val lostWalletUM = ResetCardScreenState.WarningUM(
isAcceptCondition1Checked = isAccepted, isChecked = false,
isResetButtonEnabled = isResetButtonEnabled, type = ResetCardScreenState.WarningType.LOST_WALLET_ACCESS,
) description = TextReference.Res(id = R.string.reset_card_to_factory_condition_1),
)
val lostPasswordUM = ResetCardScreenState.WarningUM(
isChecked = false,
type = ResetCardScreenState.WarningType.LOST_PASSWORD_RESTORE,
description = TextReference.Res(R.string.reset_card_to_factory_condition_2),
)
val lostTangemPayUM = ResetCardScreenState.WarningUM(
isChecked = false,
type = ResetCardScreenState.WarningType.LOST_TANGEM_PAY,
description = TextReference.Res(R.string.reset_card_to_factory_condition_3),
)
warningsMap = buildMap {
put(ResetCardScreenState.WarningType.LOST_WALLET_ACCESS, lostWalletUM)
if (shouldShowResetPasswordButton) {
put(ResetCardScreenState.WarningType.LOST_PASSWORD_RESTORE, lostPasswordUM)
}
if (shouldShowResetTangemPayButton) {
put(ResetCardScreenState.WarningType.LOST_TANGEM_PAY, lostTangemPayUM)
}
} }
return warningsMap.values.toImmutableList()
} }
private fun toggleSecondCondition(isAccepted: Boolean) { private fun toggleCondition(type: ResetCardScreenState.WarningType) {
screenState.update { prevState -> screenState.update { prevState ->
val isResetButtonEnabled = prevState.isAcceptCondition1Checked && isAccepted warningsMap[type]?.let { current ->
warningsMap = warningsMap + (type to current.copy(isChecked = !current.isChecked))
}
prevState.copy( prevState.copy(
isAcceptCondition2Checked = isAccepted, warningsToShow = warningsMap.values.toImmutableList(),
isResetButtonEnabled = isResetButtonEnabled, isResetButtonEnabled = warningsMap.values.all { it.isChecked },
) )
} }
} }

View file

@ -1,7 +1,7 @@
package com.tangem.tap.network.auth package com.tangem.tap.network.auth
import com.tangem.datasource.local.config.environment.EnvironmentConfigStorage import com.tangem.datasource.local.config.environment.EnvironmentConfigStorage
import com.tangem.domain.staking.model.ethpool.P2PStakingConfig import com.tangem.domain.staking.model.ethpool.P2PEthPoolStakingConfig
import com.tangem.lib.auth.P2PEthPoolAuthProvider import com.tangem.lib.auth.P2PEthPoolAuthProvider
internal class DefaultP2PEthPoolAuthProvider( internal class DefaultP2PEthPoolAuthProvider(
@ -12,6 +12,6 @@ internal class DefaultP2PEthPoolAuthProvider(
val keys = environmentConfigStorage.getConfigSync().p2pApiKey val keys = environmentConfigStorage.getConfigSync().p2pApiKey
?: error("No P2P api keys provided") ?: error("No P2P api keys provided")
return if (P2PStakingConfig.USE_TESTNET) keys.hoodi else keys.mainnet return if (P2PEthPoolStakingConfig.USE_TESTNET) keys.hoodi else keys.mainnet
} }
} }

View file

@ -16,6 +16,9 @@ import com.tangem.features.createwalletselection.CreateWalletSelectionComponent
import com.tangem.features.createwalletstart.CreateWalletStartComponent import com.tangem.features.createwalletstart.CreateWalletStartComponent
import com.tangem.features.details.component.DetailsComponent import com.tangem.features.details.component.DetailsComponent
import com.tangem.features.disclaimer.api.components.DisclaimerComponent import com.tangem.features.disclaimer.api.components.DisclaimerComponent
import com.tangem.features.feed.entry.components.FeedEntryComponent
import com.tangem.features.feed.entry.components.FeedEntryRoute
import com.tangem.features.feed.entry.featuretoggle.FeedFeatureToggle
import com.tangem.features.home.api.HomeComponent import com.tangem.features.home.api.HomeComponent
import com.tangem.features.hotwallet.* import com.tangem.features.hotwallet.*
import com.tangem.features.kyc.KycComponent import com.tangem.features.kyc.KycComponent
@ -38,10 +41,7 @@ import com.tangem.features.staking.api.StakingComponent
import com.tangem.features.swap.SwapComponent import com.tangem.features.swap.SwapComponent
import com.tangem.features.tangempay.components.TangemPayDetailsContainerComponent import com.tangem.features.tangempay.components.TangemPayDetailsContainerComponent
import com.tangem.features.tangempay.components.TangemPayOnboardingComponent import com.tangem.features.tangempay.components.TangemPayOnboardingComponent
import com.tangem.features.tangempay.components.TangemPayOnboardingComponent.Params.ContinueOnboarding import com.tangem.features.tangempay.components.TangemPayOnboardingComponent.Params.*
import com.tangem.features.tangempay.components.TangemPayOnboardingComponent.Params.Deeplink
import com.tangem.features.tangempay.components.TangemPayOnboardingComponent.Params.FromBannerOnMain
import com.tangem.features.tangempay.components.TangemPayOnboardingComponent.Params.FromBannerInSettings
import com.tangem.features.tokendetails.TokenDetailsComponent import com.tangem.features.tokendetails.TokenDetailsComponent
import com.tangem.features.wallet.WalletEntryComponent import com.tangem.features.wallet.WalletEntryComponent
import com.tangem.features.walletconnect.components.WalletConnectEntryComponent import com.tangem.features.walletconnect.components.WalletConnectEntryComponent
@ -121,6 +121,8 @@ internal class ChildFactory @Inject constructor(
private val yieldSupplyPromoComponentFactory: YieldSupplyPromoComponent.Factory, private val yieldSupplyPromoComponentFactory: YieldSupplyPromoComponent.Factory,
private val yieldSupplyActiveComponentFactory: YieldSupplyActiveComponent.Factory, private val yieldSupplyActiveComponentFactory: YieldSupplyActiveComponent.Factory,
private val hotWalletFeatureToggles: HotWalletFeatureToggles, private val hotWalletFeatureToggles: HotWalletFeatureToggles,
private val feedEntryComponentFactory: FeedEntryComponent.Factory,
private val feedFeatureToggle: FeedFeatureToggle,
) { ) {
@Suppress("LongMethod", "CyclomaticComplexMethod") @Suppress("LongMethod", "CyclomaticComplexMethod")
@ -204,21 +206,39 @@ internal class ChildFactory @Inject constructor(
) )
} }
is AppRoute.MarketsTokenDetails -> { is AppRoute.MarketsTokenDetails -> {
createComponentChild( if (feedFeatureToggle.isFeedEnabled) {
context = context, createComponentChild(
params = MarketsTokenDetailsComponent.Params( context = context,
token = route.token, params = FeedEntryRoute.MarketTokenDetails(
appCurrency = route.appCurrency, token = route.token,
shouldShowPortfolio = route.shouldShowPortfolio, appCurrency = route.appCurrency,
analyticsParams = route.analyticsParams?.let { params -> shouldShowPortfolio = route.shouldShowPortfolio,
MarketsTokenDetailsComponent.AnalyticsParams( analyticsParams = route.analyticsParams?.let { params ->
blockchain = params.blockchain, FeedEntryRoute.MarketTokenDetails.AnalyticsParams(
source = params.source, blockchain = params.blockchain,
) source = params.source,
}, )
), },
componentFactory = marketsTokenDetailsComponentFactory, ),
) componentFactory = feedEntryComponentFactory,
)
} else {
createComponentChild(
context = context,
params = MarketsTokenDetailsComponent.Params(
token = route.token,
appCurrency = route.appCurrency,
shouldShowPortfolio = route.shouldShowPortfolio,
analyticsParams = route.analyticsParams?.let { params ->
MarketsTokenDetailsComponent.AnalyticsParams(
blockchain = params.blockchain,
source = params.source,
)
},
),
componentFactory = marketsTokenDetailsComponentFactory,
)
}
} }
is AppRoute.Onramp -> { is AppRoute.Onramp -> {
createComponentChild( createComponentChild(
@ -311,7 +331,7 @@ internal class ChildFactory @Inject constructor(
params = StakingComponent.Params( params = StakingComponent.Params(
userWalletId = route.userWalletId, userWalletId = route.userWalletId,
cryptoCurrency = route.cryptoCurrency, cryptoCurrency = route.cryptoCurrency,
yieldId = route.yieldId, integrationId = route.integrationId,
), ),
componentFactory = stakingComponentFactory, componentFactory = stakingComponentFactory,
) )
@ -424,6 +444,7 @@ internal class ChildFactory @Inject constructor(
cardId = route.cardId, cardId = route.cardId,
isActiveBackupStatus = route.isActiveBackupStatus, isActiveBackupStatus = route.isActiveBackupStatus,
backupCardsCount = route.backupCardsCount, backupCardsCount = route.backupCardsCount,
hasTangemPay = route.hasTangemPay,
), ),
componentFactory = resetCardComponentFactory, componentFactory = resetCardComponentFactory,
) )

View file

@ -19,7 +19,7 @@ dependencies {
implementation(projects.domain.models) implementation(projects.domain.models)
implementation(projects.domain.tokens.models) implementation(projects.domain.tokens.models)
implementation(projects.domain.wallets.models) implementation(projects.domain.wallets.models)
implementation(projects.domain.staking.models) implementation(projects.domain.staking)
implementation(projects.domain.markets.models) implementation(projects.domain.markets.models)
implementation(projects.domain.onramp.models) implementation(projects.domain.onramp.models)
implementation(projects.domain.appCurrency.models) implementation(projects.domain.appCurrency.models)

View file

@ -7,6 +7,7 @@ import android.os.Bundle
import com.tangem.common.routing.bundle.RouteBundleParams import com.tangem.common.routing.bundle.RouteBundleParams
import com.tangem.common.routing.bundle.bundle import com.tangem.common.routing.bundle.bundle
import com.tangem.common.routing.entity.InitScreenLaunchMode import com.tangem.common.routing.entity.InitScreenLaunchMode
import com.tangem.domain.staking.model.StakingIntegrationID
import com.tangem.core.decompose.navigation.Route import com.tangem.core.decompose.navigation.Route
import com.tangem.domain.appcurrency.model.AppCurrency import com.tangem.domain.appcurrency.model.AppCurrency
import com.tangem.domain.feedback.models.WalletMetaInfo import com.tangem.domain.feedback.models.WalletMetaInfo
@ -112,6 +113,7 @@ sealed class AppRoute(val path: String) : Route {
val cardId: String, val cardId: String,
val isActiveBackupStatus: Boolean, val isActiveBackupStatus: Boolean,
val backupCardsCount: Int, val backupCardsCount: Int,
val hasTangemPay: Boolean,
) : AppRoute( ) : AppRoute(
path = "/reset_to_factory" + path = "/reset_to_factory" +
"/${userWalletId.stringValue}" + "/${userWalletId.stringValue}" +
@ -208,8 +210,8 @@ sealed class AppRoute(val path: String) : Route {
data class Staking( data class Staking(
val userWalletId: UserWalletId, val userWalletId: UserWalletId,
val cryptoCurrency: CryptoCurrency, val cryptoCurrency: CryptoCurrency,
val yieldId: String, val integrationId: StakingIntegrationID,
) : AppRoute(path = "/staking/${userWalletId.stringValue}/${cryptoCurrency.id.value}/$yieldId") ) : AppRoute(path = "/staking/${userWalletId.stringValue}/${cryptoCurrency.id.value}/${integrationId.value}")
@Serializable @Serializable
data class PushNotification( data class PushNotification(

View file

@ -8,7 +8,7 @@ import com.tangem.domain.staking.model.ethpool.P2PEthPoolVault
import java.math.BigDecimal import java.math.BigDecimal
/** /**
* Factory for creating mock P2P ETH Pool account responses for testing * Factory for creating mock P2PEthPool account responses for testing
*/ */
object MockP2PEthPoolAccountResponseFactory { object MockP2PEthPoolAccountResponseFactory {

View file

@ -19,7 +19,6 @@ import com.tangem.domain.models.account.Account
import com.tangem.domain.models.account.AccountId import com.tangem.domain.models.account.AccountId
import com.tangem.domain.models.quote.PriceChange import com.tangem.domain.models.quote.PriceChange
import com.tangem.utils.converter.Converter import com.tangem.utils.converter.Converter
import com.tangem.utils.extensions.isZero
import java.math.BigDecimal import java.math.BigDecimal
class AccountCryptoPortfolioItemStateConverter( class AccountCryptoPortfolioItemStateConverter(
@ -41,14 +40,11 @@ class AccountCryptoPortfolioItemStateConverter(
private fun Account.CryptoPortfolio.mapToContentState( private fun Account.CryptoPortfolio.mapToContentState(
fiatBalance: TotalFiatBalance.Loaded, fiatBalance: TotalFiatBalance.Loaded,
): TokenItemState.Content { ): TokenItemState.Content {
val subtitle2State = when (fiatBalance.amount.isZero()) { val subtitle2State = priceChangeLce?.fold(
true -> null ifLoading = { priceChange -> priceChange?.toSubtitle2State() ?: Subtitle2State.Loading },
false -> priceChangeLce?.fold( ifError = { null },
ifLoading = { priceChange -> priceChange?.toSubtitle2State() ?: Subtitle2State.Loading }, ifContent = { priceChange -> priceChange.toSubtitle2State() },
ifError = { null }, )
ifContent = { priceChange -> priceChange.toSubtitle2State() },
)
}
return TokenItemState.Content( return TokenItemState.Content(
id = account.accountId.toItemId(), id = account.accountId.toItemId(),
iconState = AccountIconItemStateConverter.convert(this), iconState = AccountIconItemStateConverter.convert(this),

View file

@ -6,6 +6,7 @@ import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.text.TextAutoSize
import androidx.compose.material3.Text import androidx.compose.material3.Text
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment import androidx.compose.ui.Alignment
@ -19,7 +20,6 @@ import androidx.compose.ui.unit.dp
import com.tangem.common.ui.account.AccountTitle import com.tangem.common.ui.account.AccountTitle
import com.tangem.common.ui.amountScreen.models.AmountState import com.tangem.common.ui.amountScreen.models.AmountState
import com.tangem.common.ui.amountScreen.preview.AmountStatePreviewData import com.tangem.common.ui.amountScreen.preview.AmountStatePreviewData
import com.tangem.core.ui.components.ResizableText
import com.tangem.core.ui.components.SpacerH import com.tangem.core.ui.components.SpacerH
import com.tangem.core.ui.components.currency.icon.CurrencyIcon import com.tangem.core.ui.components.currency.icon.CurrencyIcon
import com.tangem.core.ui.format.bigdecimal.crypto import com.tangem.core.ui.format.bigdecimal.crypto
@ -66,11 +66,14 @@ fun AmountBlock(amountState: AmountState, isClickDisabled: Boolean, isEditingDis
state = amountState.tokenIconState, state = amountState.tokenIconState,
iconSize = 40.dp, iconSize = 40.dp,
) )
ResizableText( Text(
text = firstAmount, text = firstAmount,
style = TangemTheme.typography.h2, style = TangemTheme.typography.h2,
color = TangemTheme.colors.text.primary1, color = TangemTheme.colors.text.primary1,
textAlign = TextAlign.Center, textAlign = TextAlign.Center,
autoSize = TextAutoSize.StepBased(
maxFontSize = TangemTheme.typography.h2.fontSize,
),
maxLines = 1, maxLines = 1,
modifier = Modifier modifier = Modifier
.fillMaxWidth() .fillMaxWidth()

View file

@ -4,6 +4,7 @@ import android.content.res.Configuration
import androidx.compose.foundation.background import androidx.compose.foundation.background
import androidx.compose.foundation.clickable import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.* import androidx.compose.foundation.layout.*
import androidx.compose.foundation.text.TextAutoSize
import androidx.compose.material3.Text import androidx.compose.material3.Text
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment import androidx.compose.ui.Alignment
@ -18,7 +19,6 @@ import com.tangem.common.ui.account.AccountTitle
import com.tangem.common.ui.account.AccountTitleUM import com.tangem.common.ui.account.AccountTitleUM
import com.tangem.common.ui.amountScreen.models.AmountState import com.tangem.common.ui.amountScreen.models.AmountState
import com.tangem.common.ui.amountScreen.preview.AmountStatePreviewData import com.tangem.common.ui.amountScreen.preview.AmountStatePreviewData
import com.tangem.core.ui.components.ResizableText
import com.tangem.core.ui.components.SpacerWMax import com.tangem.core.ui.components.SpacerWMax
import com.tangem.core.ui.components.currency.icon.CurrencyIcon import com.tangem.core.ui.components.currency.icon.CurrencyIcon
import com.tangem.core.ui.components.currency.icon.CurrencyIconState import com.tangem.core.ui.components.currency.icon.CurrencyIconState
@ -128,21 +128,23 @@ private fun AmountBlockV2(
.padding(top = 8.dp) .padding(top = 8.dp)
.weight(1f), .weight(1f),
) { ) {
ResizableText( Text(
text = firstAmount, text = firstAmount,
style = TangemTheme.typography.h2, style = TangemTheme.typography.h2,
color = TangemTheme.colors.text.primary1, color = TangemTheme.colors.text.primary1,
maxLines = 1, maxLines = 1,
autoSize = TextAutoSize.StepBased(maxFontSize = TangemTheme.typography.h2.fontSize),
modifier = Modifier.testTag(BaseAmountBlockTestTags.PRIMARY_AMOUNT), modifier = Modifier.testTag(BaseAmountBlockTestTags.PRIMARY_AMOUNT),
) )
Row( Row(
horizontalArrangement = Arrangement.spacedBy(4.dp), horizontalArrangement = Arrangement.spacedBy(4.dp),
) { ) {
ResizableText( Text(
text = secondAmount, text = secondAmount,
style = TangemTheme.typography.body2, style = TangemTheme.typography.body2,
color = TangemTheme.colors.text.tertiary, color = TangemTheme.colors.text.tertiary,
maxLines = 1, maxLines = 1,
autoSize = TextAutoSize.StepBased(maxFontSize = TangemTheme.typography.body2.fontSize),
modifier = Modifier.testTag(BaseAmountBlockTestTags.SECONDARY_AMOUNT), modifier = Modifier.testTag(BaseAmountBlockTestTags.SECONDARY_AMOUNT),
) )
extraContent() extraContent()

View file

@ -1,6 +1,7 @@
package com.tangem.common.ui.news package com.tangem.common.ui.news
import android.content.res.Configuration import android.content.res.Configuration
import androidx.compose.foundation.Image
import androidx.compose.foundation.background import androidx.compose.foundation.background
import androidx.compose.foundation.layout.* import androidx.compose.foundation.layout.*
import androidx.compose.foundation.rememberScrollState import androidx.compose.foundation.rememberScrollState
@ -12,6 +13,9 @@ import androidx.compose.runtime.Composable
import androidx.compose.runtime.remember import androidx.compose.runtime.remember
import androidx.compose.ui.Alignment import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.vector.ImageVector
import androidx.compose.ui.res.vectorResource
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
@ -82,6 +86,7 @@ private fun TrendingArticle(articleConfigUM: ArticleConfigUM) {
style = TangemTheme.typography.h3, style = TangemTheme.typography.h3,
maxLines = 3, maxLines = 3,
overflow = TextOverflow.Ellipsis, overflow = TextOverflow.Ellipsis,
textAlign = TextAlign.Center,
) )
SpacerH(8.dp) SpacerH(8.dp)
@ -100,6 +105,41 @@ private fun TrendingArticle(articleConfigUM: ArticleConfigUM) {
} }
} }
@Composable
fun ShowMoreArticlesCard(modifier: Modifier = Modifier, onClick: () -> Unit) {
BlockCard(
modifier = modifier,
onClick = onClick,
colors = TangemBlockCardColors.copy(containerColor = TangemTheme.colors.background.action),
) {
Column(
horizontalAlignment = Alignment.CenterHorizontally,
modifier = Modifier
.fillMaxSize()
.padding(vertical = 31.dp, horizontal = 12.dp),
) {
Image(
imageVector = ImageVector.vectorResource(R.drawable.ic_show_more_news_48),
contentDescription = stringResourceSafe(R.string.common_show_more),
)
SpacerH(16.dp)
Text(
text = stringResourceSafe(R.string.news_all_news),
style = TangemTheme.typography.subtitle1,
color = TangemTheme.colors.text.primary1,
)
Text(
text = stringResourceSafe(R.string.news_stay_in_the_loop),
style = TangemTheme.typography.caption2,
color = TangemTheme.colors.text.tertiary,
)
}
}
}
@Composable @Composable
private fun DefaultArticle(articleConfigUM: ArticleConfigUM) { private fun DefaultArticle(articleConfigUM: ArticleConfigUM) {
Column(modifier = Modifier.padding(12.dp)) { Column(modifier = Modifier.padding(12.dp)) {

View file

@ -23,7 +23,8 @@ import com.tangem.domain.models.currency.yieldSupplyKey
import com.tangem.domain.models.staking.StakingBalance import com.tangem.domain.models.staking.StakingBalance
import com.tangem.domain.staking.model.StakingAvailability import com.tangem.domain.staking.model.StakingAvailability
import com.tangem.domain.staking.model.StakingOption import com.tangem.domain.staking.model.StakingOption
import com.tangem.domain.staking.model.stakekit.Yield import com.tangem.domain.staking.model.common.RewardInfo
import com.tangem.domain.staking.model.common.RewardType
import com.tangem.domain.staking.utils.getTotalWithRewardsStakingBalance import com.tangem.domain.staking.utils.getTotalWithRewardsStakingBalance
import com.tangem.lib.crypto.BlockchainUtils import com.tangem.lib.crypto.BlockchainUtils
import com.tangem.utils.StringsSigns.DASH_SIGN import com.tangem.utils.StringsSigns.DASH_SIGN
@ -247,9 +248,9 @@ class TokenItemStateConverter(
stakingApyMap = stakingApyMap, stakingApyMap = stakingApyMap,
) )
val rewardTypeRes = when (stakingInfo.rewardType) { val rewardTypeRes = when (stakingInfo.rewardType) {
Yield.RewardType.APR -> R.string.staking_apr_earn_badge RewardType.APR -> R.string.staking_apr_earn_badge
Yield.RewardType.UNKNOWN, RewardType.UNKNOWN,
Yield.RewardType.APY, RewardType.APY,
null, null,
-> R.string.yield_module_earn_badge -> R.string.yield_module_earn_badge
} }
@ -281,10 +282,11 @@ class TokenItemStateConverter(
val stakeKitBalance = stakingBalance as? StakingBalance.Data.StakeKit val stakeKitBalance = stakingBalance as? StakingBalance.Data.StakeKit
val rateInfo = when (val stakingOptions = stakingAvailability.option) { val rateInfo = when (val stakingOptions = stakingAvailability.option) {
is StakingOption.P2P -> { is StakingOption.P2PEthPool -> {
// P2P or no balance: use preferred validators RewardInfo(
// TODO add p2p logic rate = stakingOptions.apy,
null type = RewardType.APY,
)
} }
is StakingOption.StakeKit -> if (stakeKitBalance != null) { is StakingOption.StakeKit -> if (stakeKitBalance != null) {
val validatorsByAddress = stakingOptions.yield.validators.associateBy { it.address } val validatorsByAddress = stakingOptions.yield.validators.associateBy { it.address }
@ -458,7 +460,7 @@ class TokenItemStateConverter(
private data class StakingLocalInfo( private data class StakingLocalInfo(
val rate: BigDecimal?, val rate: BigDecimal?,
val isActive: Boolean, val isActive: Boolean,
val rewardType: Yield.RewardType?, val rewardType: RewardType?,
) )
private data class EarnApyInfo( private data class EarnApyInfo(

View file

@ -1,7 +1,7 @@
package com.tangem.datasource.api.common.config package com.tangem.datasource.api.common.config
import com.tangem.datasource.BuildConfig import com.tangem.datasource.BuildConfig
import com.tangem.domain.staking.model.ethpool.P2PStakingConfig import com.tangem.domain.staking.model.ethpool.P2PEthPoolStakingConfig
import com.tangem.lib.auth.P2PEthPoolAuthProvider import com.tangem.lib.auth.P2PEthPoolAuthProvider
import com.tangem.utils.ProviderSuspend import com.tangem.utils.ProviderSuspend
@ -23,7 +23,7 @@ internal class P2PEthPool(
private fun getInitialEnvironment(): ApiEnvironment { private fun getInitialEnvironment(): ApiEnvironment {
return when (BuildConfig.BUILD_TYPE) { return when (BuildConfig.BUILD_TYPE) {
MOCKED_BUILD_TYPE -> ApiEnvironment.MOCK MOCKED_BUILD_TYPE -> ApiEnvironment.MOCK
else -> if (P2PStakingConfig.USE_TESTNET) ApiEnvironment.DEV else ApiEnvironment.PROD else -> if (P2PEthPoolStakingConfig.USE_TESTNET) ApiEnvironment.DEV else ApiEnvironment.PROD
} }
} }

View file

@ -29,7 +29,7 @@ data class P2PEthPoolBroadcastResponse(
) )
/** /**
* Transaction status from P2P API * Transaction status from P2PEthPool API
*/ */
@JsonClass(generateAdapter = false) @JsonClass(generateAdapter = false)
enum class P2PEthPoolTxStatusDTO { enum class P2PEthPoolTxStatusDTO {

View file

@ -4,9 +4,9 @@ import com.squareup.moshi.Json
import com.squareup.moshi.JsonClass import com.squareup.moshi.JsonClass
/** /**
* Error response structure for P2P.org API * Error response structure for P2P.org eth pooled API
* *
* All P2P API endpoints return errors in this format * All P2PEthPool API endpoints return errors in this format
*/ */
@JsonClass(generateAdapter = true) @JsonClass(generateAdapter = true)
data class P2PEthPoolErrorResponse( data class P2PEthPoolErrorResponse(

View file

@ -6,7 +6,7 @@ import com.squareup.moshi.JsonClass
/** /**
* Unified response wrapper for all P2P.org API responses * Unified response wrapper for all P2P.org API responses
* *
* All P2P API endpoints return responses in this format: * All P2PEthPool API endpoints return responses in this format:
* ```json * ```json
* { * {
* "error": null | { code, message, name, errors }, * "error": null | { code, message, name, errors },

View file

@ -2,6 +2,7 @@ package com.tangem.datasource.api.ethpool.models.response
import com.squareup.moshi.Json import com.squareup.moshi.Json
import com.squareup.moshi.JsonClass import com.squareup.moshi.JsonClass
import java.math.BigDecimal
/** /**
* Response for GET /api/v1/staking/pool/{network}/vaults * Response for GET /api/v1/staking/pool/{network}/vaults
@ -15,7 +16,7 @@ data class P2PEthPoolVaultsResponse(
) )
/** /**
* Network identifier in P2P API * Network identifier in P2PEthPool API
*/ */
@JsonClass(generateAdapter = false) @JsonClass(generateAdapter = false)
enum class P2PEthPoolNetworkDTO { enum class P2PEthPoolNetworkDTO {
@ -33,15 +34,15 @@ data class P2PEthPoolVaultDTO(
@Json(name = "displayName") @Json(name = "displayName")
val displayName: String, val displayName: String,
@Json(name = "apy") @Json(name = "apy")
val apy: Double, val apy: BigDecimal,
@Json(name = "baseApy") @Json(name = "baseApy")
val baseApy: Double, val baseApy: BigDecimal,
@Json(name = "capacity") @Json(name = "capacity")
val capacity: Double, val capacity: BigDecimal,
@Json(name = "totalAssets") @Json(name = "totalAssets")
val totalAssets: Double, val totalAssets: BigDecimal,
@Json(name = "feePercent") @Json(name = "feePercent")
val feePercent: Double, val feePercent: BigDecimal,
@Json(name = "isPrivate") @Json(name = "isPrivate")
val isPrivate: Boolean, val isPrivate: Boolean,
@Json(name = "isGenesis") @Json(name = "isGenesis")

View file

@ -80,7 +80,7 @@ internal object StakingStoreModule {
@Provides @Provides
@Singleton @Singleton
fun provideP2PBalancesPersistenceStore( fun provideP2PEthPoolBalancesPersistenceStore(
@NetworkMoshi moshi: Moshi, @NetworkMoshi moshi: Moshi,
@ApplicationContext context: Context, @ApplicationContext context: Context,
dispatchers: CoroutineDispatcherProvider, dispatchers: CoroutineDispatcherProvider,
@ -91,7 +91,7 @@ internal object StakingStoreModule {
types = mapWithStringKeyTypes(valueTypes = setTypes<P2PEthPoolAccountResponse>()), types = mapWithStringKeyTypes(valueTypes = setTypes<P2PEthPoolAccountResponse>()),
defaultValue = emptyMap(), defaultValue = emptyMap(),
), ),
produceFile = { context.dataStoreFile(fileName = "p2p_balances") }, produceFile = { context.dataStoreFile(fileName = "p2p_eth_pool_balances") },
scope = CoroutineScope(context = dispatchers.io + SupervisorJob()), scope = CoroutineScope(context = dispatchers.io + SupervisorJob()),
) )
} }

View file

@ -8,7 +8,7 @@ import kotlinx.coroutines.flow.Flow
* (similar to StakingYieldsStore for StakeKit yields) * (similar to StakingYieldsStore for StakeKit yields)
* *
* Vault is ETH-specific concept for pooled staking. * Vault is ETH-specific concept for pooled staking.
* For other blockchains, P2P may use different structures. * For other blockchains, P2PEthPool may use different structures.
*/ */
interface P2PEthPoolVaultsStore { interface P2PEthPoolVaultsStore {
@ -23,7 +23,7 @@ interface P2PEthPoolVaultsStore {
suspend fun getSync(): List<P2PEthPoolVault> suspend fun getSync(): List<P2PEthPoolVault>
/** /**
* Store vaults from P2P API * Store vaults from P2PEthPool API
*/ */
suspend fun store(vaults: List<P2PEthPoolVault>) suspend fun store(vaults: List<P2PEthPoolVault>)
} }

View file

@ -12,7 +12,7 @@ import com.tangem.datasource.api.common.config.ApiConfig.Companion.MOCKED_BUILD_
import com.tangem.datasource.api.common.config.ApiConfig.Companion.RELEASE_BUILD_TYPE import com.tangem.datasource.api.common.config.ApiConfig.Companion.RELEASE_BUILD_TYPE
import com.tangem.datasource.api.common.config.managers.MockEnvironmentConfigStorage.Companion.BLOCK_AID_API_KEY import com.tangem.datasource.api.common.config.managers.MockEnvironmentConfigStorage.Companion.BLOCK_AID_API_KEY
import com.tangem.datasource.api.common.config.managers.MockEnvironmentConfigStorage.Companion.TANGEM_API_KEY import com.tangem.datasource.api.common.config.managers.MockEnvironmentConfigStorage.Companion.TANGEM_API_KEY
import com.tangem.domain.staking.model.ethpool.P2PStakingConfig import com.tangem.domain.staking.model.ethpool.P2PEthPoolStakingConfig
import com.tangem.lib.auth.ExpressAuthProvider import com.tangem.lib.auth.ExpressAuthProvider
import com.tangem.lib.auth.P2PEthPoolAuthProvider import com.tangem.lib.auth.P2PEthPoolAuthProvider
import com.tangem.lib.auth.StakeKitAuthProvider import com.tangem.lib.auth.StakeKitAuthProvider
@ -300,7 +300,7 @@ internal class ProdApiConfigsManagerTest {
} }
private fun createP2PModel(): TestModel { private fun createP2PModel(): TestModel {
val (environment, baseUrl) = if (P2PStakingConfig.USE_TESTNET) { val (environment, baseUrl) = if (P2PEthPoolStakingConfig.USE_TESTNET) {
ApiEnvironment.DEV to "https://api-test.p2p.org/" ApiEnvironment.DEV to "https://api-test.p2p.org/"
} else { } else {
ApiEnvironment.PROD to "https://api.p2p.org/" ApiEnvironment.PROD to "https://api.p2p.org/"

View file

@ -1,184 +0,0 @@
package com.tangem.core.ui.components
import androidx.annotation.FloatRange
import androidx.compose.foundation.layout.wrapContentHeight
import androidx.compose.material3.LocalTextStyle
import androidx.compose.material3.Text
import androidx.compose.runtime.*
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.drawWithContent
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.text.TextStyle
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.TextUnit
import androidx.compose.ui.unit.sp
private const val COEFFICIENT = 0.8f
@Suppress("MagicNumber")
@Composable
fun ResizableText(
text: String,
fontSizeRange: FontSizeRange,
modifier: Modifier = Modifier,
color: Color = Color.Unspecified,
overflow: TextOverflow = TextOverflow.Clip,
maxLines: Int = Int.MAX_VALUE,
style: TextStyle = LocalTextStyle.current,
) {
val fontSizeValue = remember { mutableFloatStateOf(fontSizeRange.max.value) }
val readyToDraw = remember { mutableStateOf(false) }
val textState = remember { mutableStateOf(text) }
if (textState.value != text) {
readyToDraw.value = false
fontSizeValue.floatValue = fontSizeRange.max.value
textState.value = text
}
Text(
text = text,
modifier = modifier.drawWithContent { if (readyToDraw.value) drawContent() },
color = color,
fontSize = fontSizeValue.floatValue.sp,
overflow = overflow,
softWrap = false,
maxLines = maxLines,
onTextLayout = { textLayoutResult ->
if (textLayoutResult.hasVisualOverflow) {
val nextFontSizeValue = fontSizeValue.floatValue - fontSizeRange.step.value
if (nextFontSizeValue <= fontSizeRange.min.value) {
fontSizeValue.floatValue = fontSizeRange.min.value
readyToDraw.value = true
} else {
fontSizeValue.floatValue = nextFontSizeValue * COEFFICIENT
}
} else {
readyToDraw.value = true
}
},
style = style,
)
}
/**
* A Composable function that displays text which can be resized based on its content's overflow.
*
* This function draws text on the screen and checks if it overflows. If the text overflows,
* its font size is reduced recursively until it either fits the available space or reaches a
* specified minimum font size.
*/
@Composable
fun ResizableText(
text: String,
modifier: Modifier = Modifier,
color: Color = Color.Unspecified,
textAlign: TextAlign? = null,
overflow: TextOverflow = TextOverflow.Clip,
softWrap: Boolean = true,
maxLines: Int = Int.MAX_VALUE,
style: TextStyle = LocalTextStyle.current,
minFontSize: TextUnit = TextUnit.Unspecified,
@FloatRange(from = 0.0, to = 1.0, fromInclusive = false, toInclusive = false)
reduceFactor: Double = 0.9,
) {
var fontSize by remember { mutableStateOf(style.fontSize) }
var isReadyToDraw by remember { mutableStateOf(value = false) }
Text(
modifier = modifier
.drawWithContent {
if (isReadyToDraw) drawContent()
}
.wrapContentHeight(),
text = text,
color = color,
fontSize = fontSize,
textAlign = textAlign,
overflow = overflow,
softWrap = softWrap,
maxLines = maxLines,
style = style,
onTextLayout = { result ->
fun reduceFontSize() {
val reducedFontSize = fontSize * reduceFactor
if (minFontSize != TextUnit.Unspecified && reducedFontSize <= minFontSize) {
fontSize = minFontSize
isReadyToDraw = true
} else {
fontSize = reducedFontSize
}
}
if (result.hasVisualOverflow) {
reduceFontSize()
} else {
isReadyToDraw = true
}
},
)
}
@Composable
fun ResizableText(
text: String,
fontSizeValue: TextUnit,
fontSizeRange: FontSizeRange,
onFontSizeChange: (Float) -> Unit,
modifier: Modifier = Modifier,
color: Color = Color.Unspecified,
overflow: TextOverflow = TextOverflow.Clip,
maxLines: Int = Int.MAX_VALUE,
style: TextStyle = LocalTextStyle.current,
) {
val readyToDraw = remember { mutableStateOf(false) }
val textState = remember { mutableStateOf(text) }
if (textState.value != text) {
readyToDraw.value = false
onFontSizeChange(fontSizeRange.max.value)
textState.value = text
}
Text(
text = text,
modifier = modifier.drawWithContent { if (readyToDraw.value) drawContent() },
color = color,
fontSize = fontSizeValue.value.sp,
overflow = overflow,
softWrap = false,
maxLines = maxLines,
onTextLayout = { result ->
if (result.hasVisualOverflow) {
val nextFontSizeValue = fontSizeValue.value - fontSizeRange.step.value
if (nextFontSizeValue <= fontSizeRange.min.value) {
onFontSizeChange(fontSizeRange.min.value)
readyToDraw.value = true
} else {
val newSizeValue = nextFontSizeValue * COEFFICIENT
onFontSizeChange(newSizeValue)
}
} else {
readyToDraw.value = true
}
},
style = style,
)
}
data class FontSizeRange(
val min: TextUnit,
val max: TextUnit,
val step: TextUnit = DEFAULT_TEXT_STEP,
) {
init {
require(min < max) { "min should be less than max, $this" }
require(step.value > 0) { "step should be greater than 0, $this" }
}
companion object {
private val DEFAULT_TEXT_STEP = 1.sp
}
}

View file

@ -15,6 +15,8 @@ import com.tangem.core.ui.res.TangemThemePreview
fun AppBarWithBackButtonAndIcon( fun AppBarWithBackButtonAndIcon(
onBackClick: () -> Unit, onBackClick: () -> Unit,
modifier: Modifier = Modifier, modifier: Modifier = Modifier,
backButtonEnabled: Boolean = true,
endButtonEnabled: Boolean = true,
text: String? = null, text: String? = null,
subtitle: String? = null, subtitle: String? = null,
@DrawableRes backIconRes: Int? = null, @DrawableRes backIconRes: Int? = null,
@ -30,11 +32,13 @@ fun AppBarWithBackButtonAndIcon(
startButton = TopAppBarButtonUM.Icon( startButton = TopAppBarButtonUM.Icon(
iconRes = backIconRes ?: R.drawable.ic_back_24, iconRes = backIconRes ?: R.drawable.ic_back_24,
onClicked = onBackClick, onClicked = onBackClick,
isEnabled = backButtonEnabled,
), ),
endButton = if (iconRes != null && onIconClick != null) { endButton = if (iconRes != null && onIconClick != null) {
TopAppBarButtonUM.Icon( TopAppBarButtonUM.Icon(
iconRes = iconRes, iconRes = iconRes,
onClicked = onIconClick, onClicked = onIconClick,
isEnabled = endButtonEnabled,
) )
} else { } else {
null null

View file

@ -16,7 +16,6 @@ import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.vector.ImageVector import androidx.compose.ui.graphics.vector.ImageVector
import androidx.compose.ui.graphics.vector.rememberVectorPainter import androidx.compose.ui.graphics.vector.rememberVectorPainter
import androidx.compose.ui.platform.LocalConfiguration import androidx.compose.ui.platform.LocalConfiguration
import androidx.compose.ui.platform.LocalDensity
import androidx.compose.ui.res.vectorResource import androidx.compose.ui.res.vectorResource
import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.tooling.preview.Preview
@ -146,7 +145,8 @@ inline fun <reified T : TangemBottomSheetConfigContent> PreviewModalBottomSheet(
sheetState = SheetState( sheetState = SheetState(
skipPartiallyExpanded = skipPartiallyExpanded, skipPartiallyExpanded = skipPartiallyExpanded,
initialValue = Expanded, initialValue = Expanded,
density = LocalDensity.current, positionalThreshold = { 0f },
velocityThreshold = { 0f },
), ),
onBack = null, onBack = null,
bsContent = { bsContent = {

View file

@ -18,7 +18,6 @@ import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.vector.ImageVector import androidx.compose.ui.graphics.vector.ImageVector
import androidx.compose.ui.graphics.vector.rememberVectorPainter import androidx.compose.ui.graphics.vector.rememberVectorPainter
import androidx.compose.ui.platform.LocalConfiguration import androidx.compose.ui.platform.LocalConfiguration
import androidx.compose.ui.platform.LocalDensity
import androidx.compose.ui.res.vectorResource import androidx.compose.ui.res.vectorResource
import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.tooling.preview.Preview
@ -141,7 +140,8 @@ inline fun <reified T : TangemBottomSheetConfigContent> PreviewModalBottomSheetW
sheetState = SheetState( sheetState = SheetState(
skipPartiallyExpanded = skipPartiallyExpanded, skipPartiallyExpanded = skipPartiallyExpanded,
initialValue = Expanded, initialValue = Expanded,
density = LocalDensity.current, positionalThreshold = { 0f },
velocityThreshold = { 0f },
), ),
onBack = null, onBack = null,
containerColor = containerColor, containerColor = containerColor,

View file

@ -134,7 +134,8 @@ inline fun <reified T : TangemBottomSheetConfigContent> PreviewBottomSheet(
sheetState = SheetState( sheetState = SheetState(
skipPartiallyExpanded = skipPartiallyExpanded, skipPartiallyExpanded = skipPartiallyExpanded,
initialValue = Expanded, initialValue = Expanded,
density = LocalDensity.current, positionalThreshold = { 0f },
velocityThreshold = { 0f },
), ),
onBack = null, onBack = null,
containerColor = containerColor, containerColor = containerColor,

View file

@ -6,6 +6,7 @@ import androidx.compose.animation.fadeIn
import androidx.compose.animation.fadeOut import androidx.compose.animation.fadeOut
import androidx.compose.animation.togetherWith import androidx.compose.animation.togetherWith
import androidx.compose.foundation.layout.* import androidx.compose.foundation.layout.*
import androidx.compose.foundation.text.TextAutoSize
import androidx.compose.material3.* import androidx.compose.material3.*
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.ReadOnlyComposable import androidx.compose.runtime.ReadOnlyComposable
@ -23,7 +24,6 @@ import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.Dp import androidx.compose.ui.unit.Dp
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp import androidx.compose.ui.unit.sp
import com.tangem.core.ui.components.ResizableText
import com.tangem.core.ui.res.TangemTheme import com.tangem.core.ui.res.TangemTheme
import com.tangem.core.ui.test.BaseButtonTestTags import com.tangem.core.ui.test.BaseButtonTestTags
import com.tangem.core.ui.utils.MultipleClickPreventer import com.tangem.core.ui.utils.MultipleClickPreventer
@ -75,7 +75,7 @@ fun TangemButton(
) )
}, },
text = { text = {
ResizableText( Text(
modifier = Modifier modifier = Modifier
.weight(1f, fill = false) .weight(1f, fill = false)
.heightIn(MinButtonContentSize, maxContentSize) .heightIn(MinButtonContentSize, maxContentSize)
@ -86,7 +86,10 @@ fun TangemButton(
textAlign = TextAlign.Center, textAlign = TextAlign.Center,
maxLines = 1, maxLines = 1,
overflow = TextOverflow.Ellipsis, overflow = TextOverflow.Ellipsis,
minFontSize = 12.sp, autoSize = TextAutoSize.StepBased(
minFontSize = 12.sp,
maxFontSize = textStyle.fontSize,
),
) )
}, },
icon = { iconResId -> icon = { iconResId ->

View file

@ -10,9 +10,8 @@ import androidx.compose.foundation.text.BasicTextField
import androidx.compose.foundation.text.KeyboardActions import androidx.compose.foundation.text.KeyboardActions
import androidx.compose.foundation.text.KeyboardOptions import androidx.compose.foundation.text.KeyboardOptions
import androidx.compose.material3.* import androidx.compose.material3.*
import androidx.compose.runtime.Composable import androidx.compose.runtime.*
import androidx.compose.runtime.Stable import androidx.compose.runtime.saveable.rememberSaveable
import androidx.compose.runtime.remember
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.focus.FocusManager import androidx.compose.ui.focus.FocusManager
import androidx.compose.ui.focus.FocusRequester import androidx.compose.ui.focus.FocusRequester
@ -51,16 +50,22 @@ fun SearchBar(
val keyboardController = LocalSoftwareKeyboardController.current val keyboardController = LocalSoftwareKeyboardController.current
val focusManager = LocalFocusManager.current val focusManager = LocalFocusManager.current
val interactionSource = remember { MutableInteractionSource() } val interactionSource = remember { MutableInteractionSource() }
var isInitialComposition by rememberSaveable { mutableStateOf(true) }
LaunchedEffect(Unit) {
isInitialComposition = false
}
BasicTextField( BasicTextField(
modifier = modifier modifier = modifier
.fillMaxWidth() .fillMaxWidth()
.heightIn(min = TangemTheme.dimens.size48) .heightIn(min = TangemTheme.dimens.size48)
.onFocusChanged { focusState -> .onFocusChanged { focusState ->
if (focusState.isFocused) { if (!isInitialComposition) {
state.onActiveChange(true) if (focusState.isFocused) {
} else { state.onActiveChange(true)
state.onActiveChange(false) } else {
state.onActiveChange(false)
}
} }
} }
.focusRequester(focusRequester) .focusRequester(focusRequester)
@ -163,6 +168,7 @@ private fun ClearButton(
focusManager.clearFocus() focusManager.clearFocus()
keyboardController?.hide() keyboardController?.hide()
state.onActiveChange(false) state.onActiveChange(false)
state.onClearClick()
}, },
) { ) {
Icon( Icon(

View file

@ -8,4 +8,5 @@ data class SearchBarUM(
val onQueryChange: (String) -> Unit, val onQueryChange: (String) -> Unit,
val isActive: Boolean, val isActive: Boolean,
val onActiveChange: (Boolean) -> Unit, val onActiveChange: (Boolean) -> Unit,
val onClearClick: () -> Unit = {},
) )

View file

@ -16,6 +16,7 @@ import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip import androidx.compose.ui.draw.clip
import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.Color
import androidx.compose.ui.platform.testTag
import androidx.compose.ui.res.painterResource import androidx.compose.ui.res.painterResource
import androidx.compose.ui.text.input.VisualTransformation import androidx.compose.ui.text.input.VisualTransformation
import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.tooling.preview.Preview
@ -31,6 +32,7 @@ import com.tangem.core.ui.extensions.resolveReference
import com.tangem.core.ui.extensions.stringReference import com.tangem.core.ui.extensions.stringReference
import com.tangem.core.ui.res.TangemTheme import com.tangem.core.ui.res.TangemTheme
import com.tangem.core.ui.res.TangemThemePreview import com.tangem.core.ui.res.TangemThemePreview
import com.tangem.core.ui.test.SendSelectNetworkFeeBottomSheetTestTags
/** /**
* [InputRowEnter](https://www.figma.com/file/14ISV23YB1yVW1uNVwqrKv/Android?type=design&node-id=2100-799&mode=design&t=IQ5lBJEkFGU4WSvi-4) * [InputRowEnter](https://www.figma.com/file/14ISV23YB1yVW1uNVwqrKv/Android?type=design&node-id=2100-799&mode=design&t=IQ5lBJEkFGU4WSvi-4)
@ -92,7 +94,7 @@ fun InputRowEnter(
modifier = Modifier modifier = Modifier
.size(16.dp) .size(16.dp)
.clip(CircleShape), .clip(CircleShape),
text = description.resolveReference(), text = description,
content = { contentModifier -> content = { contentModifier ->
Icon( Icon(
modifier = contentModifier.size(16.dp), modifier = contentModifier.size(16.dp),
@ -113,7 +115,8 @@ fun InputRowEnter(
visualTransformation = visualTransformation, visualTransformation = visualTransformation,
keyboardOptions = keyboardOptions, keyboardOptions = keyboardOptions,
modifier = Modifier modifier = Modifier
.padding(top = TangemTheme.dimens.spacing8), .padding(top = TangemTheme.dimens.spacing8)
.testTag(SendSelectNetworkFeeBottomSheetTestTags.NONCE_INPUT_TEXT_FIELD),
) )
} }
iconRes?.let { iconRes -> iconRes?.let { iconRes ->

View file

@ -11,6 +11,7 @@ import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip import androidx.compose.ui.draw.clip
import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.Color
import androidx.compose.ui.platform.testTag
import androidx.compose.ui.res.painterResource import androidx.compose.ui.res.painterResource
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import com.tangem.core.ui.R import com.tangem.core.ui.R
@ -21,6 +22,7 @@ import com.tangem.core.ui.components.tooltip.TangemTooltip
import com.tangem.core.ui.extensions.TextReference import com.tangem.core.ui.extensions.TextReference
import com.tangem.core.ui.extensions.resolveReference import com.tangem.core.ui.extensions.resolveReference
import com.tangem.core.ui.res.TangemTheme import com.tangem.core.ui.res.TangemTheme
import com.tangem.core.ui.test.SendSelectNetworkFeeBottomSheetTestTags
import com.tangem.core.ui.utils.rememberDecimalFormat import com.tangem.core.ui.utils.rememberDecimalFormat
/** /**
@ -112,6 +114,7 @@ fun InputRowEnterInfoAmount(
} }
} }
@Suppress("LongMethod")
@Composable @Composable
fun InputRowEnterInfoAmountV2( fun InputRowEnterInfoAmountV2(
title: TextReference, title: TextReference,
@ -139,20 +142,23 @@ fun InputRowEnterInfoAmountV2(
Column( Column(
modifier = Modifier modifier = Modifier
.fillMaxWidth() .fillMaxWidth()
.padding(16.dp), .padding(16.dp)
.testTag(SendSelectNetworkFeeBottomSheetTestTags.CUSTOM_INPUT_ITEM),
) { ) {
Row(verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(2.dp)) { Row(verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(2.dp)) {
Text( Text(
text = title.resolveReference(), text = title.resolveReference(),
style = TangemTheme.typography.subtitle2, style = TangemTheme.typography.subtitle2,
color = titleColor, color = titleColor,
modifier = Modifier.testTag(SendSelectNetworkFeeBottomSheetTestTags.CUSTOM_INPUT_ITEM_TITLE),
) )
if (description != null) { if (description != null) {
TangemTooltip( TangemTooltip(
modifier = Modifier modifier = Modifier
.size(16.dp) .size(16.dp)
.clip(CircleShape), .clip(CircleShape)
text = description.resolveReference(), .testTag(SendSelectNetworkFeeBottomSheetTestTags.CUSTOM_INPUT_ITEM_TOOLTIP_ICON),
text = description,
content = { contentModifier -> content = { contentModifier ->
Icon( Icon(
modifier = contentModifier.size(16.dp), modifier = contentModifier.size(16.dp),
@ -183,7 +189,8 @@ fun InputRowEnterInfoAmountV2(
backgroundColor = Color.Transparent, backgroundColor = Color.Transparent,
modifier = Modifier modifier = Modifier
.padding(top = TangemTheme.dimens.spacing8) .padding(top = TangemTheme.dimens.spacing8)
.weight(1f), .weight(1f)
.testTag(SendSelectNetworkFeeBottomSheetTestTags.CUSTOM_INPUT_ITEM_INPUT_TEXT_FIELD),
) )
info?.let { info -> info?.let { info ->
Text( Text(
@ -192,7 +199,8 @@ fun InputRowEnterInfoAmountV2(
color = infoColor, color = infoColor,
modifier = Modifier modifier = Modifier
.padding(start = TangemTheme.dimens.spacing8) .padding(start = TangemTheme.dimens.spacing8)
.align(Alignment.Bottom), .align(Alignment.Bottom)
.testTag(SendSelectNetworkFeeBottomSheetTestTags.CUSTOM_INPUT_ITEM_FIAT_AMOUNT),
) )
} }
} }

View file

@ -25,7 +25,7 @@ import com.tangem.core.ui.extensions.resolveReference
import com.tangem.core.ui.extensions.resourceReference import com.tangem.core.ui.extensions.resourceReference
import com.tangem.core.ui.res.TangemTheme import com.tangem.core.ui.res.TangemTheme
import com.tangem.core.ui.res.TangemThemePreview import com.tangem.core.ui.res.TangemThemePreview
import com.tangem.core.ui.test.SelectNetworkFeeBottomSheetTestTags import com.tangem.core.ui.test.SwapSelectNetworkFeeBottomSheetTestTags
import com.tangem.utils.StringsSigns import com.tangem.utils.StringsSigns
@Composable @Composable
@ -70,7 +70,7 @@ fun SelectorRowItem(
modifier = Modifier modifier = Modifier
.fillMaxWidth() .fillMaxWidth()
.padding(paddingValues) .padding(paddingValues)
.testTag(SelectNetworkFeeBottomSheetTestTags.SELECTOR_ITEM), .testTag(SwapSelectNetworkFeeBottomSheetTestTags.SELECTOR_ITEM),
verticalAlignment = Alignment.CenterVertically, verticalAlignment = Alignment.CenterVertically,
) { ) {
Icon( Icon(

View file

@ -239,19 +239,19 @@ private fun StandardBottomSheet(
} }
val newTarget = val newTarget =
when (val oldTarget = state.anchoredDraggableState.targetValue) { when (val oldTarget = state.anchoredDraggableState.targetValue) {
Hidden -> if (newAnchors.hasAnchorFor(Hidden)) Hidden else oldTarget Hidden -> if (newAnchors.hasPositionFor(Hidden)) Hidden else oldTarget
PartiallyExpanded -> PartiallyExpanded ->
when { when {
newAnchors.hasAnchorFor(PartiallyExpanded) -> PartiallyExpanded newAnchors.hasPositionFor(PartiallyExpanded) -> PartiallyExpanded
newAnchors.hasAnchorFor(Expanded) -> Expanded newAnchors.hasPositionFor(Expanded) -> Expanded
newAnchors.hasAnchorFor(Hidden) -> Hidden newAnchors.hasPositionFor(Hidden) -> Hidden
else -> oldTarget else -> oldTarget
} }
Expanded -> Expanded ->
when { when {
newAnchors.hasAnchorFor(Expanded) -> Expanded newAnchors.hasPositionFor(Expanded) -> Expanded
newAnchors.hasAnchorFor(PartiallyExpanded) -> PartiallyExpanded newAnchors.hasPositionFor(PartiallyExpanded) -> PartiallyExpanded
newAnchors.hasAnchorFor(Hidden) -> Hidden newAnchors.hasPositionFor(Hidden) -> Hidden
else -> oldTarget else -> oldTarget
} }
} }

View file

@ -102,11 +102,11 @@ class TangemSheetState(
/** Whether the sheet has an expanded state defined. */ /** Whether the sheet has an expanded state defined. */
val hasExpandedState: Boolean val hasExpandedState: Boolean
get() = anchoredDraggableState.anchors.hasAnchorFor(Expanded) get() = anchoredDraggableState.anchors.hasPositionFor(Expanded)
/** Whether the modal bottom sheet has a partially expanded state defined. */ /** Whether the modal bottom sheet has a partially expanded state defined. */
val hasPartiallyExpandedState: Boolean val hasPartiallyExpandedState: Boolean
get() = anchoredDraggableState.anchors.hasAnchorFor(PartiallyExpanded) get() = anchoredDraggableState.anchors.hasPositionFor(PartiallyExpanded)
/** /**
* Fully expand the bottom sheet with animation and suspend until it is fully expanded or * Fully expand the bottom sheet with animation and suspend until it is fully expanded or
@ -274,8 +274,8 @@ internal fun consumeSwipeWithinBottomSheetBoundsNestedScrollConnection(
override suspend fun onPreFling(available: Velocity): Velocity { override suspend fun onPreFling(available: Velocity): Velocity {
val toFling = available.toFloat() val toFling = available.toFloat()
val currentOffset = sheetState.requireOffset() val currentOffset = sheetState.requireOffset()
val minAnchor = sheetState.anchoredDraggableState.anchors.minAnchor() val minPosition = sheetState.anchoredDraggableState.anchors.minPosition()
return if (toFling < 0 && currentOffset > minAnchor) { return if (toFling < 0 && currentOffset > minPosition) {
onFling(toFling) onFling(toFling)
// since we go to the anchor with tween settling, consume all for the best UX // since we go to the anchor with tween settling, consume all for the best UX
available available

View file

@ -1,29 +1,39 @@
package com.tangem.core.ui.components.tooltip package com.tangem.core.ui.components.tooltip
import android.content.res.Configuration
import androidx.compose.foundation.background import androidx.compose.foundation.background
import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material3.* import androidx.compose.material3.*
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.rememberCoroutineScope import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.res.painterResource import androidx.compose.ui.res.painterResource
import androidx.compose.ui.text.AnnotatedString
import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.DpSize
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import com.tangem.core.ui.R import com.tangem.core.ui.R
import com.tangem.core.ui.extensions.TextReference
import com.tangem.core.ui.extensions.clickableSingle import com.tangem.core.ui.extensions.clickableSingle
import com.tangem.core.ui.extensions.resolveAnnotatedReference
import com.tangem.core.ui.extensions.stringReference
import com.tangem.core.ui.res.LocalWindowSize
import com.tangem.core.ui.res.TangemTheme import com.tangem.core.ui.res.TangemTheme
import com.tangem.core.ui.res.TangemThemePreview import com.tangem.core.ui.res.TangemThemePreview
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
/**
* A Tangem-themed tooltip component that displays a tooltip with the provided text when the content is clicked.
*
* @param text The text to be displayed inside the tooltip.
* @param modifier The modifier to be applied to the tooltip component.
* @param enabled If false, the tooltip will not be shown when the content is clicked.
* @param content The content that triggers the tooltip when clicked.
*/
@Composable @Composable
fun TangemTooltip( fun TangemTooltip(
text: String, text: TextReference,
modifier: Modifier = Modifier, modifier: Modifier = Modifier,
enabled: Boolean = true, enabled: Boolean = true,
content: @Composable (Modifier) -> Unit, content: @Composable (Modifier) -> Unit,
@ -33,30 +43,10 @@ fun TangemTooltip(
enabled = enabled, enabled = enabled,
tooltipContent = { tooltipContent = {
Text( Text(
modifier = Modifier.background(TangemTheme.colors.icon.secondary), modifier = Modifier
text = text, .background(TangemTheme.colors.icon.secondary)
style = TangemTheme.typography.body2, .padding(horizontal = 6.dp, vertical = 8.dp),
color = TangemTheme.colors.text.primary2, text = text.resolveAnnotatedReference(),
)
},
content = content,
)
}
@Composable
fun TangemTooltip(
text: AnnotatedString,
modifier: Modifier = Modifier,
enabled: Boolean = true,
content: @Composable (Modifier) -> Unit,
) {
InternalTangemTooltip(
modifier = modifier,
enabled = enabled,
tooltipContent = {
Text(
modifier = Modifier.background(TangemTheme.colors.icon.secondary),
text = text,
style = TangemTheme.typography.body2, style = TangemTheme.typography.body2,
color = TangemTheme.colors.text.primary2, color = TangemTheme.colors.text.primary2,
) )
@ -75,16 +65,22 @@ private fun InternalTangemTooltip(
) { ) {
val tooltipState = rememberTooltipState(isPersistent = true) val tooltipState = rememberTooltipState(isPersistent = true)
val coroutineScope = rememberCoroutineScope() val coroutineScope = rememberCoroutineScope()
val windowSize = LocalWindowSize.current.width
TooltipBox( TooltipBox(
positionProvider = TooltipDefaults.rememberPlainTooltipPositionProvider(spacingBetweenTooltipAndAnchor = 8.dp), positionProvider = TooltipDefaults.rememberTooltipPositionProvider(
positioning = TooltipAnchorPosition.Above,
spacingBetweenTooltipAndAnchor = 8.dp,
),
state = tooltipState, state = tooltipState,
modifier = modifier, modifier = modifier,
tooltip = { tooltip = {
PlainTooltip( PlainTooltip(
modifier = Modifier modifier = Modifier.padding(end = 12.dp),
.fillMaxWidth() shape = RoundedCornerShape(14.dp),
.padding(horizontal = 12.dp), caretShape = TooltipDefaults.caretShape(),
caretSize = DpSize(width = 14.dp, height = 8.dp), maxWidth = windowSize - 24.dp,
contentColor = TangemTheme.colors.text.primary2, contentColor = TangemTheme.colors.text.primary2,
containerColor = TangemTheme.colors.icon.secondary, containerColor = TangemTheme.colors.icon.secondary,
content = { tooltipContent() }, content = { tooltipContent() },
@ -103,24 +99,23 @@ private fun InternalTangemTooltip(
) )
} }
@Preview // region Preview
@Composable @Composable
@Preview(showBackground = true, widthDp = 360, heightDp = 720)
@Preview(showBackground = true, widthDp = 360, heightDp = 720, uiMode = Configuration.UI_MODE_NIGHT_YES)
private fun TangemTooltip_Preview() { private fun TangemTooltip_Preview() {
TangemThemePreview { TangemThemePreview {
Box( Box(
modifier = Modifier modifier = Modifier
.size(500.dp) .fillMaxSize()
.background(TangemTheme.colors.background.secondary), .background(TangemTheme.colors.background.tertiary),
contentAlignment = Alignment.Center,
) { ) {
TangemTooltip( TangemTooltip(
modifier = Modifier modifier = Modifier,
.background(TangemTheme.colors.background.secondary) text = stringReference("Lorem ipsum dolor sit amet, consectetur adipiscing elit. Sed venenatis."),
.size(64.dp),
text = "Lorem ipsum dolor sit amet, consectetur adipiscing elit. Sed venenatis.",
content = { contentModifier -> content = { contentModifier ->
Icon( Icon(
modifier = contentModifier.size(64.dp), modifier = contentModifier,
painter = painterResource(R.drawable.ic_token_info_24), painter = painterResource(R.drawable.ic_token_info_24),
tint = TangemTheme.colors.icon.informative, tint = TangemTheme.colors.icon.informative,
contentDescription = null, contentDescription = null,

View file

@ -0,0 +1,33 @@
package com.tangem.core.ui.decompose
import androidx.compose.runtime.Composable
import androidx.compose.runtime.Stable
import androidx.compose.runtime.State
import androidx.compose.ui.Modifier
import com.tangem.core.ui.components.bottomsheets.state.BottomSheetState
/**
* An interface describing the UI part of a component for a modular BottomSheet.
*
* Designed for use in Decompose components. It separates the UI into a title and content,
* providing access to the [BottomSheetState] to react to changes in the sheet's state (collapsed/expanded).
*/
@Stable
interface ComposableModularBottomSheetContentComponent {
/**
* Renders the title of the bottom sheet.
* @param bottomSheetState The current state of the bottom sheet. This can be used, for example,
* to change navigation buttons (e.g., hiding the "Back" button when collapsed).
*/
@Composable
fun Title(bottomSheetState: State<BottomSheetState>)
/**
* Renders the main content of the bottom sheet.
* @param bottomSheetState The current state of the bottom sheet. Useful for tracking visibility
* (e.g., for analytics or lifecycle effects when the sheet is [BottomSheetState.EXPANDED]).
*/
@Composable
fun Content(bottomSheetState: State<BottomSheetState>, modifier: Modifier)
}

View file

@ -1,6 +1,8 @@
package com.tangem.core.ui.res package com.tangem.core.ui.res
import android.app.Activity import android.app.Activity
import androidx.compose.foundation.ComposeFoundationFlags
import androidx.compose.foundation.ExperimentalFoundationApi
import androidx.compose.foundation.isSystemInDarkTheme import androidx.compose.foundation.isSystemInDarkTheme
import androidx.compose.foundation.text.selection.LocalTextSelectionColors import androidx.compose.foundation.text.selection.LocalTextSelectionColors
import androidx.compose.foundation.text.selection.TextSelectionColors import androidx.compose.foundation.text.selection.TextSelectionColors
@ -27,6 +29,7 @@ import com.tangem.core.ui.windowsize.rememberWindowSize
import com.tangem.domain.apptheme.model.AppThemeMode import com.tangem.domain.apptheme.model.AppThemeMode
import com.valentinilk.shimmer.Shimmer import com.valentinilk.shimmer.Shimmer
@OptIn(ExperimentalFoundationApi::class)
@Composable @Composable
fun TangemTheme( fun TangemTheme(
activity: Activity, activity: Activity,
@ -36,6 +39,9 @@ fun TangemTheme(
overrideSystemBarColors: Boolean = true, overrideSystemBarColors: Boolean = true,
content: @Composable () -> Unit, content: @Composable () -> Unit,
) { ) {
// TODO Research and implement in redesign [REDACTED_TASK_KEY]
ComposeFoundationFlags.isPausableCompositionInPrefetchEnabled = false
val appThemeMode by uiDependencies.appThemeModeHolder.appThemeMode val appThemeMode by uiDependencies.appThemeModeHolder.appThemeMode
val windowSize = rememberWindowSize(activity = activity) val windowSize = rememberWindowSize(activity = activity)

View file

@ -1,6 +0,0 @@
package com.tangem.core.ui.test
object SelectNetworkFeeBottomSheetTestTags {
const val READ_MORE_TEXT = "SELECT_NETWORK_FEE_READ_MORE_TEXT"
const val SELECTOR_ITEM = "SELECT_NETWORK_FEE_SELECTOR_ITEM"
}

View file

@ -0,0 +1,22 @@
package com.tangem.core.ui.test
object SendSelectNetworkFeeBottomSheetTestTags {
const val REGULAR_FEE_ITEM = "SEND_SELECT_NETWORK_FEE_REGULAR_FEE_ITEM"
const val REGULAR_ITEM_ICON = "SEND_SELECT_NETWORK_FEE_REGULAR_ITEM_ICON"
const val REGULAR_ITEM_TITLE = "SEND_SELECT_NETWORK_FEE_REGULAR_ITEM_TITLE"
const val DOT_SIGN = "SEND_SELECT_NETWORK_FEE_DOT_SIGN"
const val TOKEN_AMOUNT = "SEND_SELECT_NETWORK_FEE_TOKEN_AMOUNT"
const val FIAT_AMOUNT = "SEND_SELECT_NETWORK_FEE_FIAT_AMOUNT"
const val CUSTOM_FEE_ITEM = "SEND_SELECT_NETWORK_FEE_CUSTOM_FEE_ITEM"
const val CUSTOM_ITEM_ICON = "SEND_SELECT_NETWORK_FEE_CUSTOM_ITEM_ICON"
const val CUSTOM_ITEM_TITLE = "SEND_SELECT_NETWORK_FEE_CUSTOM_ITEM_TITLE"
const val CUSTOM_INPUT_ITEM = "SEND_SELECT_NETWORK_FEE_CUSTOM_INPUT_ITEM"
const val NONCE_INPUT_ITEM = "SEND_SELECT_NETWORK_FEE_NONCE_INPUT_ITEM"
const val NONCE_INPUT_TEXT_FIELD = "SEND_SELECT_NETWORK_FEE_NONCE_INPUT_TEXT_FIELD"
const val CUSTOM_INPUT_ITEM_TITLE = "SEND_SELECT_NETWORK_FEE_CUSTOM_INPUT_ITEM_TITLE"
const val CUSTOM_INPUT_ITEM_TOOLTIP_ICON = "SEND_SELECT_NETWORK_FEE_CUSTOM_INPUT_ITEM_TOOLTIP_ICON"
const val CUSTOM_INPUT_ITEM_INPUT_TEXT_FIELD = "SEND_SELECT_NETWORK_FEE_CUSTOM_INPUT_ITEM_INPUT_TEXT_FIELD"
const val CUSTOM_INPUT_ITEM_FIAT_AMOUNT = "SEND_SELECT_NETWORK_FEE_CUSTOM_INPUT_ITEM_FIAT_AMOUNT"
}

View file

@ -0,0 +1,6 @@
package com.tangem.core.ui.test
object SwapSelectNetworkFeeBottomSheetTestTags {
const val READ_MORE_TEXT = "SWAP_SELECT_NETWORK_FEE_READ_MORE_TEXT"
const val SELECTOR_ITEM = "SWAP_SELECT_NETWORK_FEE_SELECTOR_ITEM"
}

View file

@ -0,0 +1,18 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="48dp"
android:height="48dp"
android:viewportWidth="48"
android:viewportHeight="48">
<group>
<clip-path
android:pathData="M24,0L24,0A24,24 0,0 1,48 24L48,24A24,24 0,0 1,24 48L24,48A24,24 0,0 1,0 24L0,24A24,24 0,0 1,24 0z"/>
<path
android:pathData="M24,0L24,0A24,24 0,0 1,48 24L48,24A24,24 0,0 1,24 48L24,48A24,24 0,0 1,0 24L0,24A24,24 0,0 1,24 0z"
android:strokeAlpha="0.1"
android:fillColor="#0099FF"
android:fillAlpha="0.1"/>
<path
android:pathData="M15.782,24.005C15.781,23.508 16.184,23.105 16.681,23.104L29.128,23.099L23.92,17.891C23.569,17.539 23.569,16.97 23.92,16.618C24.272,16.267 24.842,16.267 25.193,16.618L31.943,23.368C32.295,23.72 32.295,24.289 31.943,24.641L25.193,31.391C24.842,31.742 24.272,31.742 23.92,31.391C23.569,31.039 23.569,30.47 23.92,30.118L29.139,24.899L16.682,24.904C16.185,24.905 15.782,24.502 15.782,24.005Z"
android:fillColor="#0099FF"/>
</group>
</vector>

View file

@ -26,10 +26,10 @@ import kotlinx.coroutines.withContext
import timber.log.Timber import timber.log.Timber
/** /**
* P2P staking repository implementation * P2PEthPool staking repository implementation
*/ */
internal class DefaultP2PEthPoolRepository( internal class DefaultP2PEthPoolRepository(
private val p2pApi: P2PEthPoolApi, private val p2pEthPoolApi: P2PEthPoolApi,
private val p2pEthPoolVaultsStore: P2PEthPoolVaultsStore, private val p2pEthPoolVaultsStore: P2PEthPoolVaultsStore,
private val dispatchers: CoroutineDispatcherProvider, private val dispatchers: CoroutineDispatcherProvider,
private val stakingFeatureToggles: StakingFeatureToggles, private val stakingFeatureToggles: StakingFeatureToggles,
@ -44,7 +44,7 @@ internal class DefaultP2PEthPoolRepository(
override suspend fun fetchVaults(network: P2PEthPoolNetwork) { override suspend fun fetchVaults(network: P2PEthPoolNetwork) {
val vaults = if (stakingFeatureToggles.isEthStakingEnabled) { val vaults = if (stakingFeatureToggles.isEthStakingEnabled) {
getVaults(network).getOrElse { error -> getVaults(network).getOrElse { error ->
Timber.e("Error fetching P2P vaults: $error") Timber.e("Error fetching P2PEthPool vaults: $error")
emptyList() emptyList()
} }
} else { } else {
@ -56,8 +56,7 @@ internal class DefaultP2PEthPoolRepository(
override suspend fun getVaults(network: P2PEthPoolNetwork): Either<StakingError, List<P2PEthPoolVault>> = either { override suspend fun getVaults(network: P2PEthPoolNetwork): Either<StakingError, List<P2PEthPoolVault>> = either {
withContext(dispatchers.io) { withContext(dispatchers.io) {
val response = p2pApi.getVaults(network.value) when (val response = p2pEthPoolApi.getVaults(network.value)) {
when (response) {
is ApiResponse.Success -> { is ApiResponse.Success -> {
val data = response.data val data = response.data
ensure(data.error == null) { ensure(data.error == null) {
@ -83,7 +82,7 @@ internal class DefaultP2PEthPoolRepository(
vaultAddress = vaultAddress, vaultAddress = vaultAddress,
amount = amount.toDoubleOrNull() ?: raise(StakingError.InvalidAmount("Invalid amount format: $amount")), amount = amount.toDoubleOrNull() ?: raise(StakingError.InvalidAmount("Invalid amount format: $amount")),
) )
val response = p2pApi.createDepositTransaction(network.value, requestBody) val response = p2pEthPoolApi.createDepositTransaction(network.value, requestBody)
when (response) { when (response) {
is ApiResponse.Success -> { is ApiResponse.Success -> {
val data = response.data val data = response.data
@ -108,7 +107,7 @@ internal class DefaultP2PEthPoolRepository(
stakerPublicKey = stakerPublicKey, stakerPublicKey = stakerPublicKey,
stakeTransactionHash = stakeTransactionHash, stakeTransactionHash = stakeTransactionHash,
) )
val response = p2pApi.createUnstakeTransaction(network.value, requestBody) val response = p2pEthPoolApi.createUnstakeTransaction(network.value, requestBody)
when (response) { when (response) {
is ApiResponse.Success -> { is ApiResponse.Success -> {
val data = response.data val data = response.data
@ -140,7 +139,7 @@ internal class DefaultP2PEthPoolRepository(
): Either<StakingError, P2PEthPoolUnsignedTx> = either { ): Either<StakingError, P2PEthPoolUnsignedTx> = either {
withContext(dispatchers.io) { withContext(dispatchers.io) {
val requestBody = P2PEthPoolWithdrawRequest(stakerAddress = stakerAddress) val requestBody = P2PEthPoolWithdrawRequest(stakerAddress = stakerAddress)
val response = p2pApi.createWithdrawTransaction(network.value, requestBody) val response = p2pEthPoolApi.createWithdrawTransaction(network.value, requestBody)
when (response) { when (response) {
is ApiResponse.Success -> { is ApiResponse.Success -> {
val data = response.data val data = response.data
@ -161,7 +160,7 @@ internal class DefaultP2PEthPoolRepository(
): Either<StakingError, P2PEthPoolBroadcastResult> = either { ): Either<StakingError, P2PEthPoolBroadcastResult> = either {
withContext(dispatchers.io) { withContext(dispatchers.io) {
val requestBody = P2PEthPoolBroadcastRequest(signedTransaction = signedTransaction) val requestBody = P2PEthPoolBroadcastRequest(signedTransaction = signedTransaction)
val response = p2pApi.broadcastTransaction(network.value, requestBody) val response = p2pEthPoolApi.broadcastTransaction(network.value, requestBody)
when (response) { when (response) {
is ApiResponse.Success -> { is ApiResponse.Success -> {
val data = response.data val data = response.data
@ -182,7 +181,7 @@ internal class DefaultP2PEthPoolRepository(
vaultAddress: String, vaultAddress: String,
): Either<StakingError, P2PEthPoolAccount> = either { ): Either<StakingError, P2PEthPoolAccount> = either {
withContext(dispatchers.io) { withContext(dispatchers.io) {
val response = p2pApi.getAccountInfo(network.value, delegatorAddress, vaultAddress) val response = p2pEthPoolApi.getAccountInfo(network.value, delegatorAddress, vaultAddress)
when (response) { when (response) {
is ApiResponse.Success -> { is ApiResponse.Success -> {
val data = response.data val data = response.data
@ -204,7 +203,7 @@ internal class DefaultP2PEthPoolRepository(
period: Int?, period: Int?,
): Either<StakingError, List<P2PEthPoolReward>> = either { ): Either<StakingError, List<P2PEthPoolReward>> = either {
withContext(dispatchers.io) { withContext(dispatchers.io) {
val response = p2pApi.getRewards( val response = p2pEthPoolApi.getRewards(
network = network.value, network = network.value,
delegatorAddress = delegatorAddress, delegatorAddress = delegatorAddress,
vaultAddress = vaultAddress, vaultAddress = vaultAddress,
@ -224,6 +223,10 @@ internal class DefaultP2PEthPoolRepository(
} }
} }
override fun getVaultsFlow(): Flow<List<P2PEthPoolVault>> {
return p2pEthPoolVaultsStore.get()
}
override fun getStakingAvailability(): Flow<StakingAvailability> { override fun getStakingAvailability(): Flow<StakingAvailability> {
return getVaultsFlow() return getVaultsFlow()
.distinctUntilChanged() .distinctUntilChanged()
@ -231,7 +234,7 @@ internal class DefaultP2PEthPoolRepository(
if (vaults.isEmpty()) { if (vaults.isEmpty()) {
return@map StakingAvailability.TemporaryUnavailable return@map StakingAvailability.TemporaryUnavailable
} else { } else {
StakingAvailability.Available(StakingOption.P2P(vaults)) StakingAvailability.Available(StakingOption.P2PEthPool(vaults))
} }
} }
} }
@ -241,15 +244,11 @@ internal class DefaultP2PEthPoolRepository(
return if (vaults.isEmpty()) { return if (vaults.isEmpty()) {
StakingAvailability.TemporaryUnavailable StakingAvailability.TemporaryUnavailable
} else { } else {
StakingAvailability.Available(StakingOption.P2P(vaults)) StakingAvailability.Available(StakingOption.P2PEthPool(vaults))
} }
} }
private suspend fun getVaultsSync(): List<P2PEthPoolVault> { private suspend fun getVaultsSync(): List<P2PEthPoolVault> {
return p2pEthPoolVaultsStore.getSync() return p2pEthPoolVaultsStore.getSync()
} }
private fun getVaultsFlow(): Flow<List<P2PEthPoolVault>> {
return p2pEthPoolVaultsStore.get()
}
} }

View file

@ -364,6 +364,7 @@ internal class DefaultStakeKitRepository(
} }
override fun getStakingAvailability( override fun getStakingAvailability(
integrationId: StakingIntegrationID.StakeKit,
rawCurrencyId: CryptoCurrency.RawID, rawCurrencyId: CryptoCurrency.RawID,
symbol: String, symbol: String,
): Flow<StakingAvailability> { ): Flow<StakingAvailability> {
@ -381,7 +382,7 @@ internal class DefaultStakeKitRepository(
) )
if (prefetchedYield != null) { if (prefetchedYield != null) {
StakingAvailability.Available(StakingOption.StakeKit(prefetchedYield)) StakingAvailability.Available(StakingOption.StakeKit(integrationId, prefetchedYield))
} else { } else {
StakingAvailability.TemporaryUnavailable StakingAvailability.TemporaryUnavailable
} }
@ -389,6 +390,7 @@ internal class DefaultStakeKitRepository(
} }
override suspend fun getStakingAvailabilitySync( override suspend fun getStakingAvailabilitySync(
integrationId: StakingIntegrationID.StakeKit,
rawCurrencyId: CryptoCurrency.RawID, rawCurrencyId: CryptoCurrency.RawID,
symbol: String, symbol: String,
): StakingAvailability { ): StakingAvailability {
@ -404,7 +406,7 @@ internal class DefaultStakeKitRepository(
) )
return if (prefetchedYield != null) { return if (prefetchedYield != null) {
StakingAvailability.Available(StakingOption.StakeKit(prefetchedYield)) StakingAvailability.Available(StakingOption.StakeKit(integrationId, prefetchedYield))
} else { } else {
StakingAvailability.TemporaryUnavailable StakingAvailability.TemporaryUnavailable
} }

View file

@ -29,12 +29,12 @@ internal class DefaultStakingErrorResolver(
is StakingError.DomainError -> { is StakingError.DomainError -> {
analyticsEventHandler.send(StakingAnalyticsEvent.DomainError(error)) analyticsEventHandler.send(StakingAnalyticsEvent.DomainError(error))
} }
// P2P errors // P2PEthPool errors
is StakingError.InvalidAmount, is StakingError.InvalidAmount,
is StakingError.DataError, is StakingError.DataError,
is StakingError.UnknownError, is StakingError.UnknownError,
-> { -> {
// P2P errors - no specific analytics event yet // P2PEthPool errors - no specific analytics event yet
} }
} }

View file

@ -61,8 +61,9 @@ internal class DefaultStakingRepository(
val stakingIntegration = StakingIntegrationID.create(currencyId = cryptoCurrency.id) val stakingIntegration = StakingIntegrationID.create(currencyId = cryptoCurrency.id)
val availabilityFlow = when (stakingIntegration) { val availabilityFlow = when (stakingIntegration) {
is StakingIntegrationID.P2P -> p2pEthPoolRepository.getStakingAvailability() StakingIntegrationID.P2PEthPool -> p2pEthPoolRepository.getStakingAvailability()
is StakingIntegrationID.StakeKit -> stakeKitRepository.getStakingAvailability( is StakingIntegrationID.StakeKit -> stakeKitRepository.getStakingAvailability(
stakingIntegration,
rawCurrencyId, rawCurrencyId,
cryptoCurrency.symbol, cryptoCurrency.symbol,
) )
@ -94,8 +95,9 @@ internal class DefaultStakingRepository(
?: return StakingAvailability.Unavailable ?: return StakingAvailability.Unavailable
return when (stakingIntegration) { return when (stakingIntegration) {
is StakingIntegrationID.P2P -> p2pEthPoolRepository.getStakingAvailabilitySync() StakingIntegrationID.P2PEthPool -> p2pEthPoolRepository.getStakingAvailabilitySync()
is StakingIntegrationID.StakeKit -> stakeKitRepository.getStakingAvailabilitySync( is StakingIntegrationID.StakeKit -> stakeKitRepository.getStakingAvailabilitySync(
stakingIntegration,
rawCurrencyId, rawCurrencyId,
cryptoCurrency.symbol, cryptoCurrency.symbol,
) )

View file

@ -5,6 +5,8 @@ import com.tangem.datasource.api.stakekit.models.response.model.YieldDTO
import com.tangem.datasource.api.stakekit.models.response.model.YieldDTO.MetadataDTO.RewardScheduleDTO import com.tangem.datasource.api.stakekit.models.response.model.YieldDTO.MetadataDTO.RewardScheduleDTO
import com.tangem.datasource.api.stakekit.models.response.model.YieldDTO.ValidatorDTO.ValidatorStatusDTO import com.tangem.datasource.api.stakekit.models.response.model.YieldDTO.ValidatorDTO.ValidatorStatusDTO
import com.tangem.datasource.local.token.converter.YieldTokenConverter import com.tangem.datasource.local.token.converter.YieldTokenConverter
import com.tangem.domain.staking.model.common.RewardInfo
import com.tangem.domain.staking.model.common.RewardType
import com.tangem.domain.staking.model.stakekit.AddressArgument import com.tangem.domain.staking.model.stakekit.AddressArgument
import com.tangem.domain.staking.model.stakekit.Yield import com.tangem.domain.staking.model.stakekit.Yield
import com.tangem.domain.staking.model.stakekit.Yield.Metadata.RewardSchedule import com.tangem.domain.staking.model.stakekit.Yield.Metadata.RewardSchedule
@ -124,7 +126,7 @@ internal object YieldConverter : Converter<YieldDTO, Yield> {
) )
} }
private fun convertValidator(validatorDTO: YieldDTO.ValidatorDTO, rewardType: Yield.RewardType): Yield.Validator { private fun convertValidator(validatorDTO: YieldDTO.ValidatorDTO, rewardType: RewardType): Yield.Validator {
val address = validatorDTO.address.asMandatory("address") val address = validatorDTO.address.asMandatory("address")
return Yield.Validator( return Yield.Validator(
@ -142,7 +144,7 @@ internal object YieldConverter : Converter<YieldDTO, Yield> {
) )
} }
private fun createRewardInfo(validatorDTO: YieldDTO.ValidatorDTO, rewardType: Yield.RewardType): Yield.RewardInfo? { private fun createRewardInfo(validatorDTO: YieldDTO.ValidatorDTO, rewardType: RewardType): RewardInfo? {
val aprOrApy = validatorDTO.apr val aprOrApy = validatorDTO.apr
val commission = validatorDTO.commission val commission = validatorDTO.commission
// gross = net / (1 - commission) // gross = net / (1 - commission)
@ -162,17 +164,17 @@ internal object YieldConverter : Converter<YieldDTO, Yield> {
} else { } else {
netApy netApy
} }
grossAprOrApy?.let { Yield.RewardInfo(rate = it, type = rewardType) } grossAprOrApy?.let { RewardInfo(rate = it, type = rewardType) }
} catch (_: Exception) { } catch (_: Exception) {
aprOrApy?.let { Yield.RewardInfo(rate = it, type = rewardType) } aprOrApy?.let { RewardInfo(rate = it, type = rewardType) }
} }
} }
private fun convertRewardType(rewardTypeDTO: YieldDTO.RewardTypeDTO): Yield.RewardType { private fun convertRewardType(rewardTypeDTO: YieldDTO.RewardTypeDTO): RewardType {
return when (rewardTypeDTO) { return when (rewardTypeDTO) {
YieldDTO.RewardTypeDTO.APY -> Yield.RewardType.APY YieldDTO.RewardTypeDTO.APY -> RewardType.APY
YieldDTO.RewardTypeDTO.APR -> Yield.RewardType.APR YieldDTO.RewardTypeDTO.APR -> RewardType.APR
else -> Yield.RewardType.UNKNOWN else -> RewardType.UNKNOWN
} }
} }

View file

@ -9,7 +9,7 @@ import com.tangem.utils.converter.Converter
import org.joda.time.Instant import org.joda.time.Instant
/** /**
* Converter from P2P Account Info Response to Domain model * Converter from P2PEthPool Account Info Response to Domain model
*/ */
internal object P2PEthPoolAccountConverter : Converter<P2PEthPoolAccountResponse, P2PEthPoolAccount> { internal object P2PEthPoolAccountConverter : Converter<P2PEthPoolAccountResponse, P2PEthPoolAccount> {

View file

@ -8,7 +8,7 @@ import com.tangem.utils.converter.Converter
import java.math.BigDecimal import java.math.BigDecimal
/** /**
* Converter from P2P Broadcast Transaction Response to Domain model * Converter from P2PEthPool Broadcast Transaction Response to Domain model
*/ */
internal object P2PEthPoolBroadcastResultConverter : Converter<P2PEthPoolBroadcastResponse, P2PEthPoolBroadcastResult> { internal object P2PEthPoolBroadcastResultConverter : Converter<P2PEthPoolBroadcastResponse, P2PEthPoolBroadcastResult> {

View file

@ -6,7 +6,7 @@ import com.tangem.domain.staking.model.stakekit.StakingError
import com.tangem.utils.converter.Converter import com.tangem.utils.converter.Converter
/** /**
* Converter from P2P Error Response to Domain StakingError * Converter from P2PEthPool Error Response to Domain StakingError
*/ */
@Suppress("MagicNumber") @Suppress("MagicNumber")
internal object P2PEthPoolErrorConverter : Converter<P2PEthPoolErrorResponse, StakingError> { internal object P2PEthPoolErrorConverter : Converter<P2PEthPoolErrorResponse, StakingError> {

View file

@ -5,7 +5,7 @@ import com.tangem.domain.staking.model.ethpool.P2PEthPoolReward
import com.tangem.utils.converter.Converter import com.tangem.utils.converter.Converter
/** /**
* Converter from P2P Reward Entry DTO to Domain model * Converter from P2PEthPool Reward Entry DTO to Domain model
*/ */
internal object P2PEthPoolRewardConverter : Converter<P2PEthPoolRewardDTO, P2PEthPoolReward> { internal object P2PEthPoolRewardConverter : Converter<P2PEthPoolRewardDTO, P2PEthPoolReward> {

View file

@ -8,17 +8,18 @@ import com.tangem.domain.models.StatusSource
import com.tangem.domain.models.staking.* import com.tangem.domain.models.staking.*
import com.tangem.domain.staking.model.StakingIntegrationID import com.tangem.domain.staking.model.StakingIntegrationID
import kotlinx.datetime.Instant import kotlinx.datetime.Instant
import java.math.BigDecimal
/** Converts P2P ETH Pool API response to [StakingBalance.Data.P2P] */ /** Converts P2PEthPool API response to [StakingBalance] */
internal object P2PStakingBalanceConverter { internal object P2PEthPoolStakingBalanceConverter {
fun convert(response: P2PEthPoolAccountResponse, source: StatusSource): StakingBalance.Data.P2P { fun convert(response: P2PEthPoolAccountResponse, source: StatusSource): StakingBalance {
val stakingId = StakingID( val stakingId = StakingID(
integrationId = StakingIntegrationID.P2P.EthereumPooled.value, integrationId = StakingIntegrationID.P2PEthPool.value,
address = response.delegatorAddress, address = response.delegatorAddress,
) )
val account = P2PStakingAccount( val account = P2PEthPoolStakingAccount(
delegatorAddress = response.delegatorAddress, delegatorAddress = response.delegatorAddress,
vaultAddress = response.vaultAddress, vaultAddress = response.vaultAddress,
stake = convertStake(response.stake), stake = convertStake(response.stake),
@ -27,29 +28,40 @@ internal object P2PStakingBalanceConverter {
exitQueue = convertExitQueue(response.exitQueue), exitQueue = convertExitQueue(response.exitQueue),
) )
return StakingBalance.Data.P2P( val hasActivePosition = account.stake.assets > BigDecimal.ZERO ||
stakingId = stakingId, account.exitQueue.total > BigDecimal.ZERO ||
source = source, account.availableToWithdraw > BigDecimal.ZERO
account = account,
) return if (hasActivePosition) {
StakingBalance.Data.P2PEthPool(
stakingId = stakingId,
source = source,
account = account,
)
} else {
StakingBalance.Empty(
stakingId = stakingId,
source = source,
)
}
} }
private fun convertStake(dto: P2PEthPoolStakeDTO): P2PStake { private fun convertStake(dto: P2PEthPoolStakeDTO): P2PEthPoolStake {
return P2PStake( return P2PEthPoolStake(
assets = dto.assets, assets = dto.assets,
totalEarnedAssets = dto.totalEarnedAssets, totalEarnedAssets = dto.totalEarnedAssets,
) )
} }
private fun convertExitQueue(dto: P2PEthPoolExitQueueDTO): P2PExitQueue { private fun convertExitQueue(dto: P2PEthPoolExitQueueDTO): P2PEthPoolExitQueue {
return P2PExitQueue( return P2PEthPoolExitQueue(
total = dto.total.toBigDecimal(), total = dto.total.toBigDecimal(),
requests = dto.requests.map(::convertExitRequest), requests = dto.requests.map(::convertExitRequest),
) )
} }
private fun convertExitRequest(dto: P2PEthPoolExitRequestDTO): P2PExitRequest { private fun convertExitRequest(dto: P2PEthPoolExitRequestDTO): P2PEthPoolExitRequest {
return P2PExitRequest( return P2PEthPoolExitRequest(
ticket = dto.ticket, ticket = dto.ticket,
totalAssets = dto.totalAssets.toBigDecimal(), totalAssets = dto.totalAssets.toBigDecimal(),
timestamp = Instant.fromEpochSeconds(dto.timestamp), timestamp = Instant.fromEpochSeconds(dto.timestamp),

View file

@ -6,7 +6,7 @@ import com.tangem.utils.converter.Converter
import java.math.BigDecimal import java.math.BigDecimal
/** /**
* Converter from P2P Unsigned Transaction DTO to Domain model * Converter from P2PEthPool Unsigned Transaction DTO to Domain model
*/ */
internal object P2PEthPoolUnsignedTxConverter : Converter<P2PEthPoolUnsignedTxDTO, P2PEthPoolUnsignedTx> { internal object P2PEthPoolUnsignedTxConverter : Converter<P2PEthPoolUnsignedTxDTO, P2PEthPoolUnsignedTx> {

View file

@ -3,21 +3,26 @@ package com.tangem.data.staking.converters.ethpool
import com.tangem.datasource.api.ethpool.models.response.P2PEthPoolVaultDTO import com.tangem.datasource.api.ethpool.models.response.P2PEthPoolVaultDTO
import com.tangem.domain.staking.model.ethpool.P2PEthPoolVault import com.tangem.domain.staking.model.ethpool.P2PEthPoolVault
import com.tangem.utils.converter.Converter import com.tangem.utils.converter.Converter
import java.math.BigDecimal
import java.math.RoundingMode
/** /**
* Converter from P2P Vault DTO to Domain model * Converter from P2PEthPool Vault DTO to Domain model
*/ */
internal object P2PEthPoolVaultConverter : Converter<P2PEthPoolVaultDTO, P2PEthPoolVault> { internal object P2PEthPoolVaultConverter : Converter<P2PEthPoolVaultDTO, P2PEthPoolVault> {
private val HUNDRED = BigDecimal(100)
private const val DIVIDE_SCALE = 8
override fun convert(value: P2PEthPoolVaultDTO): P2PEthPoolVault { override fun convert(value: P2PEthPoolVaultDTO): P2PEthPoolVault {
return P2PEthPoolVault( return P2PEthPoolVault(
vaultAddress = value.vaultAddress, vaultAddress = value.vaultAddress,
displayName = value.displayName, displayName = value.displayName,
apy = value.apy.toBigDecimal(), apy = value.apy.divide(HUNDRED, DIVIDE_SCALE, RoundingMode.HALF_UP),
baseApy = value.baseApy.toBigDecimal(), baseApy = value.baseApy.divide(HUNDRED, DIVIDE_SCALE, RoundingMode.HALF_UP),
capacity = value.capacity.toBigDecimal(), capacity = value.capacity,
totalAssets = value.totalAssets.toBigDecimal(), totalAssets = value.totalAssets,
feePercent = value.feePercent.toBigDecimal(), feePercent = value.feePercent,
isPrivate = value.isPrivate, isPrivate = value.isPrivate,
isGenesis = value.isGenesis, isGenesis = value.isGenesis,
isSmoothingPool = value.isSmoothingPool, isSmoothingPool = value.isSmoothingPool,

View file

@ -1,92 +0,0 @@
package com.tangem.data.staking.converters.ethpool
import com.tangem.domain.models.StatusSource
import com.tangem.domain.models.staking.*
import com.tangem.domain.staking.model.ethpool.P2PEthPoolAccount
import com.tangem.domain.staking.model.ethpool.P2PEthPoolVault
import java.math.BigDecimal
/**
* tmp solution before facade implementation
*/
internal object P2PYieldBalanceConverter {
private const val ETH_DECIMALS = 18
private const val ETH_SYMBOL = "ETH"
private const val ETH_NAME = "Ethereum"
private const val ETH_COINGECKO_ID = "ethereum"
fun convert(
account: P2PEthPoolAccount,
vault: P2PEthPoolVault,
address: String,
source: StatusSource,
): YieldBalance {
val integrationId = "p2p-ethereum-pooled"
val stakingId = StakingID(
integrationId = integrationId,
address = address,
)
val balanceItems = buildBalanceItems(account, vault)
return if (balanceItems.isEmpty()) {
YieldBalance.Empty(stakingId = stakingId, source = source)
} else {
YieldBalance.Data(
stakingId = stakingId,
source = source,
balance = YieldBalanceItem(
items = balanceItems,
integrationId = integrationId,
),
)
}
}
private fun buildBalanceItems(account: P2PEthPoolAccount, vault: P2PEthPoolVault): List<BalanceItem> = buildList {
if (account.stake.assets > BigDecimal.ZERO) {
add(
createBalanceItem(
groupId = "p2p-staked",
amount = account.stake.assets,
type = BalanceType.STAKED,
validatorAddress = vault.vaultAddress,
),
)
}
}
private fun createBalanceItem(
groupId: String,
amount: BigDecimal,
type: BalanceType,
validatorAddress: String,
): BalanceItem {
return BalanceItem(
groupId = groupId,
token = createEthToken(),
type = type,
amount = amount,
rawCurrencyId = ETH_COINGECKO_ID,
validatorAddress = validatorAddress,
date = null,
pendingActions = emptyList(),
pendingActionsConstraints = emptyList(),
isPending = false,
)
}
private fun createEthToken(): YieldToken {
return YieldToken(
name = ETH_NAME,
network = NetworkType.ETHEREUM,
symbol = ETH_SYMBOL,
decimals = ETH_DECIMALS,
address = null,
coinGeckoId = ETH_COINGECKO_ID,
logoURI = null,
isPoints = false,
)
}
}

View file

@ -1,9 +1,9 @@
package com.tangem.data.staking.di package com.tangem.data.staking.di
import androidx.datastore.core.DataStore import androidx.datastore.core.DataStore
import com.tangem.data.staking.store.DefaultP2PBalancesStore import com.tangem.data.staking.store.DefaultP2PEthPoolBalancesStore
import com.tangem.data.staking.store.DefaultStakingBalancesStore import com.tangem.data.staking.store.DefaultStakingBalancesStore
import com.tangem.data.staking.store.P2PBalancesStore import com.tangem.data.staking.store.P2PEthPoolBalancesStore
import com.tangem.data.staking.store.StakingBalancesStore import com.tangem.data.staking.store.StakingBalancesStore
import com.tangem.datasource.api.ethpool.models.response.P2PEthPoolAccountResponse import com.tangem.datasource.api.ethpool.models.response.P2PEthPoolAccountResponse
import com.tangem.datasource.api.stakekit.models.response.model.YieldBalanceWrapperDTO import com.tangem.datasource.api.stakekit.models.response.model.YieldBalanceWrapperDTO
@ -38,11 +38,11 @@ internal object StakingBalanceSupplierModule {
@Provides @Provides
@Singleton @Singleton
fun provideP2PBalancesStore( fun provideP2PEthPoolBalancesStore(
persistenceStore: DataStore<Map<String, Set<P2PEthPoolAccountResponse>>>, persistenceStore: DataStore<Map<String, Set<P2PEthPoolAccountResponse>>>,
dispatchers: CoroutineDispatcherProvider, dispatchers: CoroutineDispatcherProvider,
): P2PBalancesStore { ): P2PEthPoolBalancesStore {
return DefaultP2PBalancesStore( return DefaultP2PEthPoolBalancesStore(
runtimeStore = RuntimeSharedStore(), runtimeStore = RuntimeSharedStore(),
persistenceStore = persistenceStore, persistenceStore = persistenceStore,
dispatchers = dispatchers, dispatchers = dispatchers,

View file

@ -75,13 +75,13 @@ internal object StakingDataModule {
@Provides @Provides
@Singleton @Singleton
fun provideP2PEthPoolRepository( fun provideP2PEthPoolRepository(
p2pApi: P2PEthPoolApi, p2pEthPoolApi: P2PEthPoolApi,
p2pEthPoolVaultsStore: P2PEthPoolVaultsStore, p2pEthPoolVaultsStore: P2PEthPoolVaultsStore,
dispatchers: CoroutineDispatcherProvider, dispatchers: CoroutineDispatcherProvider,
stakingFeatureToggles: StakingFeatureToggles, stakingFeatureToggles: StakingFeatureToggles,
): P2PEthPoolRepository { ): P2PEthPoolRepository {
return DefaultP2PEthPoolRepository( return DefaultP2PEthPoolRepository(
p2pApi = p2pApi, p2pEthPoolApi = p2pEthPoolApi,
p2pEthPoolVaultsStore = p2pEthPoolVaultsStore, p2pEthPoolVaultsStore = p2pEthPoolVaultsStore,
dispatchers = dispatchers, dispatchers = dispatchers,
stakingFeatureToggles = stakingFeatureToggles, stakingFeatureToggles = stakingFeatureToggles,

View file

@ -5,7 +5,7 @@ import arrow.core.left
import arrow.core.right import arrow.core.right
import arrow.core.toOption import arrow.core.toOption
import com.tangem.data.common.api.safeApiCall import com.tangem.data.common.api.safeApiCall
import com.tangem.data.staking.store.P2PBalancesStore import com.tangem.data.staking.store.P2PEthPoolBalancesStore
import com.tangem.data.staking.store.StakingBalancesStore import com.tangem.data.staking.store.StakingBalancesStore
import com.tangem.data.staking.utils.YieldBalanceRequestBodyFactory import com.tangem.data.staking.utils.YieldBalanceRequestBodyFactory
import com.tangem.datasource.api.common.response.ApiResponse import com.tangem.datasource.api.common.response.ApiResponse
@ -24,7 +24,8 @@ import com.tangem.domain.models.wallet.UserWallet
import com.tangem.domain.models.wallet.UserWalletId import com.tangem.domain.models.wallet.UserWalletId
import com.tangem.domain.models.wallet.isMultiCurrency import com.tangem.domain.models.wallet.isMultiCurrency
import com.tangem.domain.staking.model.StakingIntegrationID import com.tangem.domain.staking.model.StakingIntegrationID
import com.tangem.domain.staking.model.ethpool.P2PStakingConfig import com.tangem.domain.staking.model.ethpool.P2PEthPoolStakingConfig
import com.tangem.domain.staking.model.ethpool.P2PEthPoolVault
import com.tangem.domain.staking.multi.MultiStakingBalanceFetcher import com.tangem.domain.staking.multi.MultiStakingBalanceFetcher
import com.tangem.utils.coroutines.CoroutineDispatcherProvider import com.tangem.utils.coroutines.CoroutineDispatcherProvider
import com.tangem.utils.coroutines.runSuspendCatching import com.tangem.utils.coroutines.runSuspendCatching
@ -38,16 +39,16 @@ import javax.inject.Inject
/** /**
* Default implementation of [MultiStakingBalanceFetcher] * Default implementation of [MultiStakingBalanceFetcher]
* *
* Supports both StakeKit and P2P staking providers. * Supports both StakeKit and P2PEthPool staking providers.
* *
* @property userWalletsStore user wallets store * @property userWalletsStore user wallets store
* @property stakingYieldsStore staking yields store * @property stakingYieldsStore staking yields store
* @property stakingBalancesStore staking balances store (StakeKit) * @property stakingBalancesStore staking balances store (StakeKit)
* @property p2pBalancesStore P2P balances store * @property p2PEthPoolBalancesStore P2PEthPool balances store
* @property stakeKitApi stake kit API * @property stakeKitApi stake kit API
* @property p2pApi P2P ETH Pool API * @property p2pEthPoolApi P2PEthPool API
* @property p2pVaultsStore P2P vaults store * @property p2pEthPoolVaultsStore P2PEthPool vaults store
* @property dispatchers dispatchers * @property dispatchers dispatchers
* *
[REDACTED_AUTHOR] [REDACTED_AUTHOR]
*/ */
@ -56,10 +57,10 @@ internal class DefaultMultiStakingBalanceFetcher @Inject constructor(
private val userWalletsStore: UserWalletsStore, private val userWalletsStore: UserWalletsStore,
private val stakingYieldsStore: StakingYieldsStore, private val stakingYieldsStore: StakingYieldsStore,
private val stakingBalancesStore: StakingBalancesStore, private val stakingBalancesStore: StakingBalancesStore,
private val p2pBalancesStore: P2PBalancesStore, private val p2PEthPoolBalancesStore: P2PEthPoolBalancesStore,
private val stakeKitApi: StakeKitApi, private val stakeKitApi: StakeKitApi,
private val p2pApi: P2PEthPoolApi, private val p2pEthPoolApi: P2PEthPoolApi,
private val p2pVaultsStore: P2PEthPoolVaultsStore, private val p2pEthPoolVaultsStore: P2PEthPoolVaultsStore,
private val dispatchers: CoroutineDispatcherProvider, private val dispatchers: CoroutineDispatcherProvider,
) : MultiStakingBalanceFetcher { ) : MultiStakingBalanceFetcher {
@ -75,7 +76,7 @@ internal class DefaultMultiStakingBalanceFetcher @Inject constructor(
return it.left() return it.left()
} }
val (stakeKitIds, p2pIds) = stakingIds.partition { stakingId -> val (stakeKitIds, p2pEthPoolIds) = stakingIds.partition { stakingId ->
val stakingIntegrationID = StakingIntegrationID.entries.find { val stakingIntegrationID = StakingIntegrationID.entries.find {
it.value == stakingId.integrationId it.value == stakingId.integrationId
} }
@ -86,7 +87,7 @@ internal class DefaultMultiStakingBalanceFetcher @Inject constructor(
""" """
Staking IDs to fetch: Staking IDs to fetch:
- StakeKit: ${stakeKitIds.joinToString()} - StakeKit: ${stakeKitIds.joinToString()}
- P2P: ${p2pIds.joinToString()} - P2PEthPool: ${p2pEthPoolIds.joinToString()}
""".trimIndent(), """.trimIndent(),
) )
@ -96,8 +97,8 @@ internal class DefaultMultiStakingBalanceFetcher @Inject constructor(
launch { fetchStakeKitBalances(params.userWalletId, stakeKitIds.toSet()) } launch { fetchStakeKitBalances(params.userWalletId, stakeKitIds.toSet()) }
} }
if (p2pIds.isNotEmpty()) { if (p2pEthPoolIds.isNotEmpty()) {
launch { fetchP2PBalances(params.userWalletId, p2pIds.toSet()) } launch { fetchP2PBalances(params.userWalletId, p2pEthPoolIds.toSet()) }
} }
} }
} }
@ -110,8 +111,11 @@ internal class DefaultMultiStakingBalanceFetcher @Inject constructor(
stakingIds = stakeKitIds.toSet(), stakingIds = stakeKitIds.toSet(),
) )
} }
if (p2pIds.isNotEmpty()) { if (p2pEthPoolIds.isNotEmpty()) {
p2pBalancesStore.storeError(userWalletId = params.userWalletId, stakingIds = p2pIds.toSet()) p2PEthPoolBalancesStore.storeError(
userWalletId = params.userWalletId,
stakingIds = p2pEthPoolIds.toSet(),
)
} }
} }
} }
@ -128,12 +132,12 @@ internal class DefaultMultiStakingBalanceFetcher @Inject constructor(
} }
private suspend fun fetchP2PBalances(userWalletId: UserWalletId, stakingIds: Set<StakingID>) { private suspend fun fetchP2PBalances(userWalletId: UserWalletId, stakingIds: Set<StakingID>) {
p2pBalancesStore.refresh(userWalletId = userWalletId, stakingIds = stakingIds) p2PEthPoolBalancesStore.refresh(userWalletId = userWalletId, stakingIds = stakingIds)
val vaults = runSuspendCatching { p2pVaultsStore.getSync() }.getOrNull().orEmpty() val vaults = runSuspendCatching { p2pEthPoolVaultsStore.getSync() }.getOrNull().orEmpty()
if (vaults.isEmpty()) { if (vaults.isEmpty()) {
Timber.w("No P2P vaults available for $userWalletId") Timber.w("No P2PEthPool vaults available for $userWalletId, storing empty balances")
p2pBalancesStore.storeError(userWalletId = userWalletId, stakingIds = stakingIds) p2PEthPoolBalancesStore.storeEmpty(userWalletId = userWalletId, stakingIds = stakingIds)
return return
} }
@ -143,59 +147,17 @@ internal class DefaultMultiStakingBalanceFetcher @Inject constructor(
private suspend fun fetchFromP2P( private suspend fun fetchFromP2P(
userWalletId: UserWalletId, userWalletId: UserWalletId,
stakingIds: Set<StakingID>, stakingIds: Set<StakingID>,
vaults: List<com.tangem.domain.staking.model.ethpool.P2PEthPoolVault>, vaults: List<P2PEthPoolVault>,
) { ) {
safeApiCall( safeApiCall(
call = { call = {
val addresses = stakingIds.map { it.address }.toSet() val addresses = stakingIds.map { it.address }.toSet()
val responses = fetchP2PAccountResponses(vaults = vaults, addresses = addresses)
val responses = mutableSetOf<P2PEthPoolAccountResponse>() Timber.i("Successfully fetched ${responses.size} P2PEthPool balances for $userWalletId")
for (vault in vaults) {
for (address in addresses) {
runSuspendCatching {
val response = p2pApi.getAccountInfo(
network = P2PStakingConfig.activeNetwork.value,
delegatorAddress = address,
vaultAddress = vault.vaultAddress,
)
when (response) {
is ApiResponse.Success -> {
val data = response.data
if (data.error != null) {
Timber.w(
"P2P API returned error for vault ${vault.vaultAddress}, " +
"address $address: ${data.error ?: "error"}",
)
} else {
val result = requireNotNull(data.result) {
"Result is null in successful response"
}
responses.add(result)
}
}
is ApiResponse.Error -> {
Timber.w(
response.cause,
"Failed to fetch P2P balance for vault ${vault.vaultAddress}, " +
"address $address",
)
}
}
}.onFailure { error ->
Timber.w(
error,
"Failed to fetch P2P balance for vault ${vault.vaultAddress}, address $address",
)
}
}
}
Timber.i("Successfully fetched ${responses.size} P2P balances for $userWalletId")
if (responses.isNotEmpty()) { if (responses.isNotEmpty()) {
p2pBalancesStore.storeActual(userWalletId = userWalletId, values = responses) p2PEthPoolBalancesStore.storeActual(userWalletId = userWalletId, values = responses)
val missingStakingIds = stakingIds.filter { stakingId -> val missingStakingIds = stakingIds.filter { stakingId ->
responses.none { response -> responses.none { response ->
@ -205,23 +167,76 @@ internal class DefaultMultiStakingBalanceFetcher @Inject constructor(
if (missingStakingIds.isNotEmpty()) { if (missingStakingIds.isNotEmpty()) {
Timber.i("Missing responses for ${missingStakingIds.size} staking IDs: $missingStakingIds") Timber.i("Missing responses for ${missingStakingIds.size} staking IDs: $missingStakingIds")
p2pBalancesStore.storeError(userWalletId = userWalletId, stakingIds = missingStakingIds.toSet()) p2PEthPoolBalancesStore.storeError(
userWalletId = userWalletId,
stakingIds = missingStakingIds.toSet(),
)
} }
} else { } else {
Timber.i("No P2P responses received for $userWalletId") Timber.i("No P2PEthPool responses received for $userWalletId")
p2pBalancesStore.storeError(userWalletId = userWalletId, stakingIds = stakingIds) p2PEthPoolBalancesStore.storeError(userWalletId = userWalletId, stakingIds = stakingIds)
} }
}, },
onError = { throwable -> onError = { throwable ->
Timber.e(throwable, "Unable to fetch P2P balances $userWalletId") Timber.e(throwable, "Unable to fetch P2PEthPool balances $userWalletId")
p2pBalancesStore.storeError(userWalletId = userWalletId, stakingIds = stakingIds) p2PEthPoolBalancesStore.storeError(userWalletId = userWalletId, stakingIds = stakingIds)
throw throwable throw throwable
}, },
) )
} }
private suspend fun fetchP2PAccountResponses(
vaults: List<P2PEthPoolVault>,
addresses: Set<String>,
): Set<P2PEthPoolAccountResponse> {
val responses = mutableSetOf<P2PEthPoolAccountResponse>()
for (vault in vaults) {
for (address in addresses) {
runSuspendCatching {
val response = p2pEthPoolApi.getAccountInfo(
network = P2PEthPoolStakingConfig.activeNetwork.value,
delegatorAddress = address,
vaultAddress = vault.vaultAddress,
)
when (response) {
is ApiResponse.Success -> {
val data = response.data
if (data.error != null) {
Timber.w(
"P2PEthPool API returned error for vault ${vault.vaultAddress}, " +
"address $address: ${data.error ?: "error"}",
)
} else {
val result = requireNotNull(data.result) {
"Result is null in successful response"
}
responses.add(result)
}
}
is ApiResponse.Error -> {
Timber.w(
response.cause,
"Failed to fetch P2PEthPool balance for vault ${vault.vaultAddress}, " +
"address $address",
)
}
}
}.onFailure { error ->
Timber.w(
error,
"Failed to fetch P2PEthPool balance for vault ${vault.vaultAddress}, address $address",
)
}
}
}
return responses
}
private inline fun checkIsSupportedByWalletOrElse(userWalletId: UserWalletId, ifNotSupported: (Throwable) -> Unit) { private inline fun checkIsSupportedByWalletOrElse(userWalletId: UserWalletId, ifNotSupported: (Throwable) -> Unit) {
val maybeUserWallet = userWalletsStore.getSyncOrNull(key = userWalletId).toOption() val maybeUserWallet = userWalletsStore.getSyncOrNull(key = userWalletId).toOption()

View file

@ -2,7 +2,7 @@ package com.tangem.data.staking.multi
import arrow.core.Option import arrow.core.Option
import arrow.core.some import arrow.core.some
import com.tangem.data.staking.store.P2PBalancesStore import com.tangem.data.staking.store.P2PEthPoolBalancesStore
import com.tangem.data.staking.store.StakingBalancesStore import com.tangem.data.staking.store.StakingBalancesStore
import com.tangem.domain.models.staking.StakingBalance import com.tangem.domain.models.staking.StakingBalance
import com.tangem.domain.staking.multi.MultiStakingBalanceProducer import com.tangem.domain.staking.multi.MultiStakingBalanceProducer
@ -19,11 +19,11 @@ import kotlinx.coroutines.flow.onEmpty
/** /**
* Default implementation of [MultiStakingBalanceProducer] * Default implementation of [MultiStakingBalanceProducer]
* *
* Combines staking balances from both StakeKit and P2P providers. * Combines staking balances from both StakeKit and P2PEthPool providers.
* *
* @property params params * @property params params
* @property stakingBalancesStore StakeKit staking balances store * @property stakingBalancesStore StakeKit staking balances store
* @property p2pBalancesStore P2P balances store * @property p2PEthPoolBalancesStore P2PEthPool balances store
* @property dispatchers dispatchers * @property dispatchers dispatchers
* *
[REDACTED_AUTHOR] [REDACTED_AUTHOR]
@ -31,7 +31,7 @@ import kotlinx.coroutines.flow.onEmpty
internal class DefaultMultiStakingBalanceProducer @AssistedInject constructor( internal class DefaultMultiStakingBalanceProducer @AssistedInject constructor(
@Assisted val params: MultiStakingBalanceProducer.Params, @Assisted val params: MultiStakingBalanceProducer.Params,
private val stakingBalancesStore: StakingBalancesStore, private val stakingBalancesStore: StakingBalancesStore,
private val p2pBalancesStore: P2PBalancesStore, private val p2PEthPoolBalancesStore: P2PEthPoolBalancesStore,
private val dispatchers: CoroutineDispatcherProvider, private val dispatchers: CoroutineDispatcherProvider,
) : MultiStakingBalanceProducer { ) : MultiStakingBalanceProducer {
@ -39,10 +39,10 @@ internal class DefaultMultiStakingBalanceProducer @AssistedInject constructor(
override fun produce(): Flow<Set<StakingBalance>> { override fun produce(): Flow<Set<StakingBalance>> {
val stakeKitFlow = stakingBalancesStore.get(userWalletId = params.userWalletId) val stakeKitFlow = stakingBalancesStore.get(userWalletId = params.userWalletId)
val p2pFlow = p2pBalancesStore.get(userWalletId = params.userWalletId) val p2pEthPoolFlow = p2PEthPoolBalancesStore.get(userWalletId = params.userWalletId)
return combine(stakeKitFlow, p2pFlow) { stakeKitBalances, p2pBalances -> return combine(stakeKitFlow, p2pEthPoolFlow) { stakeKitBalances, p2pEthPoolBalances ->
stakeKitBalances + p2pBalances stakeKitBalances + p2pEthPoolBalances
} }
.distinctUntilChanged() .distinctUntilChanged()
.onEmpty { emit(value = hashSetOf()) } .onEmpty { emit(value = hashSetOf()) }

View file

@ -1,7 +1,7 @@
package com.tangem.data.staking.store package com.tangem.data.staking.store
import androidx.datastore.core.DataStore import androidx.datastore.core.DataStore
import com.tangem.data.staking.converters.ethpool.P2PStakingBalanceConverter import com.tangem.data.staking.converters.ethpool.P2PEthPoolStakingBalanceConverter
import com.tangem.datasource.api.ethpool.models.response.P2PEthPoolAccountResponse import com.tangem.datasource.api.ethpool.models.response.P2PEthPoolAccountResponse
import com.tangem.datasource.local.datastore.RuntimeSharedStore import com.tangem.datasource.local.datastore.RuntimeSharedStore
import com.tangem.domain.models.StatusSource import com.tangem.domain.models.StatusSource
@ -20,22 +20,22 @@ import kotlinx.coroutines.flow.map
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
internal typealias WalletIdWithP2PStakingBalances = Map<UserWalletId, Set<StakingBalance>> internal typealias WalletIdWithP2PStakingBalances = Map<UserWalletId, Set<StakingBalance>>
internal typealias WalletIdWithP2PResponses = Map<String, Set<P2PEthPoolAccountResponse>> internal typealias WalletIdWithP2PEthPoolResponses = Map<String, Set<P2PEthPoolAccountResponse>>
/** /**
* Default implementation of [P2PBalancesStore] * Default implementation of [P2PEthPoolBalancesStore]
* *
* Stores P2P ETH Pool staking balances. * Stores P2PEthPool staking balances.
* *
* @property runtimeStore runtime store * @property runtimeStore runtime store
* @property persistenceStore persistence store * @property persistenceStore persistence store
* @param dispatchers coroutine dispatchers * @param dispatchers coroutine dispatchers
*/ */
internal class DefaultP2PBalancesStore( internal class DefaultP2PEthPoolBalancesStore(
private val runtimeStore: RuntimeSharedStore<WalletIdWithP2PStakingBalances>, private val runtimeStore: RuntimeSharedStore<WalletIdWithP2PStakingBalances>,
private val persistenceStore: DataStore<WalletIdWithP2PResponses>, private val persistenceStore: DataStore<WalletIdWithP2PEthPoolResponses>,
dispatchers: CoroutineDispatcherProvider, dispatchers: CoroutineDispatcherProvider,
) : P2PBalancesStore { ) : P2PEthPoolBalancesStore {
private val scope = CoroutineScope(context = SupervisorJob() + dispatchers.io) private val scope = CoroutineScope(context = SupervisorJob() + dispatchers.io)
@ -47,7 +47,7 @@ internal class DefaultP2PBalancesStore(
value = cachedData.map { (stringWalletId, responses) -> value = cachedData.map { (stringWalletId, responses) ->
val key = UserWalletId(stringWalletId) val key = UserWalletId(stringWalletId)
val value = responses.map { response -> val value = responses.map { response ->
P2PStakingBalanceConverter.convert( P2PEthPoolStakingBalanceConverter.convert(
response = response, response = response,
source = StatusSource.CACHE, source = StatusSource.CACHE,
) )
@ -90,6 +90,15 @@ internal class DefaultP2PBalancesStore(
} }
} }
override suspend fun storeEmpty(userWalletId: UserWalletId, stakingIds: Set<StakingID>) {
updateInRuntime(
userWalletId = userWalletId,
stakingIds = stakingIds,
ifNotFound = ::createEmptyStakingBalance,
update = { it.copySealed(source = StatusSource.ACTUAL) },
)
}
override suspend fun storeError(userWalletId: UserWalletId, stakingIds: Set<StakingID>) { override suspend fun storeError(userWalletId: UserWalletId, stakingIds: Set<StakingID>) {
updateInRuntime( updateInRuntime(
userWalletId = userWalletId, userWalletId = userWalletId,
@ -108,7 +117,7 @@ internal class DefaultP2PBalancesStore(
private suspend fun storeInRuntime(userWalletId: UserWalletId, values: Set<P2PEthPoolAccountResponse>) { private suspend fun storeInRuntime(userWalletId: UserWalletId, values: Set<P2PEthPoolAccountResponse>) {
val newBalances = values.map { response -> val newBalances = values.map { response ->
P2PStakingBalanceConverter.convert( P2PEthPoolStakingBalanceConverter.convert(
response = response, response = response,
source = StatusSource.ACTUAL, source = StatusSource.ACTUAL,
) )
@ -152,7 +161,7 @@ internal class DefaultP2PBalancesStore(
current.toMutableMap().apply { current.toMutableMap().apply {
this[userWalletId.stringValue] = this[userWalletId.stringValue].orEmpty() this[userWalletId.stringValue] = this[userWalletId.stringValue].orEmpty()
.filterNot { response -> .filterNot { response ->
StakingIntegrationID.P2P.EthereumPooled.value in integrationIds StakingIntegrationID.P2PEthPool.value in integrationIds
} }
.toSet() .toSet()
} }
@ -187,5 +196,8 @@ internal class DefaultP2PBalancesStore(
} }
} }
private fun createEmptyStakingBalance(id: StakingID): StakingBalance =
StakingBalance.Empty(stakingId = id, source = StatusSource.ACTUAL)
private fun createErrorStakingBalance(id: StakingID): StakingBalance = StakingBalance.Error(stakingId = id) private fun createErrorStakingBalance(id: StakingID): StakingBalance = StakingBalance.Error(stakingId = id)
} }

View file

@ -7,9 +7,9 @@ import com.tangem.domain.models.wallet.UserWalletId
import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.Flow
/** /**
* Store for P2P ETH Pool staking balances * Store for P2PEthPool staking balances
*/ */
interface P2PBalancesStore { interface P2PEthPoolBalancesStore {
fun get(userWalletId: UserWalletId): Flow<Set<StakingBalance>> fun get(userWalletId: UserWalletId): Flow<Set<StakingBalance>>
@ -23,6 +23,8 @@ interface P2PBalancesStore {
suspend fun storeActual(userWalletId: UserWalletId, values: Set<P2PEthPoolAccountResponse>) suspend fun storeActual(userWalletId: UserWalletId, values: Set<P2PEthPoolAccountResponse>)
suspend fun storeEmpty(userWalletId: UserWalletId, stakingIds: Set<StakingID>)
suspend fun storeError(userWalletId: UserWalletId, stakingIds: Set<StakingID>) suspend fun storeError(userWalletId: UserWalletId, stakingIds: Set<StakingID>)
suspend fun clear(userWalletId: UserWalletId, stakingIds: Set<StakingID>) suspend fun clear(userWalletId: UserWalletId, stakingIds: Set<StakingID>)

View file

@ -1,6 +1,6 @@
package com.tangem.data.staking package com.tangem.data.staking
import com.tangem.data.staking.converters.ethpool.P2PStakingBalanceConverter import com.tangem.data.staking.converters.ethpool.P2PEthPoolStakingBalanceConverter
import com.tangem.datasource.api.ethpool.models.response.P2PEthPoolAccountResponse import com.tangem.datasource.api.ethpool.models.response.P2PEthPoolAccountResponse
import com.tangem.datasource.api.stakekit.models.response.model.YieldBalanceWrapperDTO import com.tangem.datasource.api.stakekit.models.response.model.YieldBalanceWrapperDTO
import com.tangem.datasource.local.token.converter.StakingBalanceConverter import com.tangem.datasource.local.token.converter.StakingBalanceConverter
@ -11,8 +11,10 @@ internal fun YieldBalanceWrapperDTO.toDomain(source: StatusSource = StatusSource
return StakingBalanceConverter(isCached = source == StatusSource.CACHE).convert(this)!! return StakingBalanceConverter(isCached = source == StatusSource.CACHE).convert(this)!!
} }
internal fun P2PEthPoolAccountResponse.toDomain(source: StatusSource = StatusSource.CACHE): StakingBalance.Data.P2P { internal fun P2PEthPoolAccountResponse.toDomain(
return P2PStakingBalanceConverter.convert( source: StatusSource = StatusSource.CACHE,
): StakingBalance {
return P2PEthPoolStakingBalanceConverter.convert(
response = this, response = this,
source = source, source = source,
) )

View file

@ -4,7 +4,7 @@ import arrow.core.toOption
import com.tangem.common.test.data.staking.MockYieldBalanceWrapperDTOFactory import com.tangem.common.test.data.staking.MockYieldBalanceWrapperDTOFactory
import com.tangem.common.test.data.staking.MockYieldDTOFactory import com.tangem.common.test.data.staking.MockYieldDTOFactory
import com.tangem.common.test.domain.wallet.MockUserWalletFactory import com.tangem.common.test.domain.wallet.MockUserWalletFactory
import com.tangem.data.staking.store.P2PBalancesStore import com.tangem.data.staking.store.P2PEthPoolBalancesStore
import com.tangem.data.staking.store.StakingBalancesStore import com.tangem.data.staking.store.StakingBalancesStore
import com.tangem.data.staking.utils.YieldBalanceRequestBodyFactory import com.tangem.data.staking.utils.YieldBalanceRequestBodyFactory
import com.tangem.datasource.api.common.response.ApiResponse import com.tangem.datasource.api.common.response.ApiResponse
@ -36,19 +36,19 @@ internal class DefaultMultiStakingBalanceFetcherTest {
private val userWalletsStore: UserWalletsStore = mockk() private val userWalletsStore: UserWalletsStore = mockk()
private val stakingYieldsStore: StakingYieldsStore = mockk() private val stakingYieldsStore: StakingYieldsStore = mockk()
private val stakingBalancesStore: StakingBalancesStore = mockk(relaxUnitFun = true) private val stakingBalancesStore: StakingBalancesStore = mockk(relaxUnitFun = true)
private val p2pBalancesStore: P2PBalancesStore = mockk(relaxUnitFun = true) private val p2PEthPoolBalancesStore: P2PEthPoolBalancesStore = mockk(relaxUnitFun = true)
private val stakeKitApi: StakeKitApi = mockk() private val stakeKitApi: StakeKitApi = mockk()
private val p2pApi: P2PEthPoolApi = mockk() private val p2pEthPoolApi: P2PEthPoolApi = mockk()
private val p2pVaultsStore: P2PEthPoolVaultsStore = mockk() private val p2pEthPoolVaultsStore: P2PEthPoolVaultsStore = mockk()
private val fetcher = DefaultMultiStakingBalanceFetcher( private val fetcher = DefaultMultiStakingBalanceFetcher(
userWalletsStore = userWalletsStore, userWalletsStore = userWalletsStore,
stakingYieldsStore = stakingYieldsStore, stakingYieldsStore = stakingYieldsStore,
stakingBalancesStore = stakingBalancesStore, stakingBalancesStore = stakingBalancesStore,
p2pBalancesStore = p2pBalancesStore, p2PEthPoolBalancesStore = p2PEthPoolBalancesStore,
stakeKitApi = stakeKitApi, stakeKitApi = stakeKitApi,
p2pApi = p2pApi, p2pEthPoolApi = p2pEthPoolApi,
p2pVaultsStore = p2pVaultsStore, p2pEthPoolVaultsStore = p2pEthPoolVaultsStore,
dispatchers = TestingCoroutineDispatcherProvider(), dispatchers = TestingCoroutineDispatcherProvider(),
) )

View file

@ -3,7 +3,7 @@ package com.tangem.data.staking.multi
import com.google.common.truth.Truth import com.google.common.truth.Truth
import com.tangem.common.test.data.staking.MockYieldBalanceWrapperDTOFactory import com.tangem.common.test.data.staking.MockYieldBalanceWrapperDTOFactory
import com.tangem.common.test.data.staking.MockP2PEthPoolAccountResponseFactory import com.tangem.common.test.data.staking.MockP2PEthPoolAccountResponseFactory
import com.tangem.data.staking.store.P2PBalancesStore import com.tangem.data.staking.store.P2PEthPoolBalancesStore
import com.tangem.data.staking.store.StakingBalancesStore import com.tangem.data.staking.store.StakingBalancesStore
import com.tangem.data.staking.toDomain import com.tangem.data.staking.toDomain
import com.tangem.domain.models.StatusSource import com.tangem.domain.models.StatusSource
@ -28,13 +28,13 @@ internal class DefaultMultiStakingBalanceProducerTest {
private val params = MultiStakingBalanceProducer.Params(userWalletId = UserWalletId("011")) private val params = MultiStakingBalanceProducer.Params(userWalletId = UserWalletId("011"))
private val stakingBalancesStore = mockk<StakingBalancesStore>() private val stakingBalancesStore = mockk<StakingBalancesStore>()
private val p2pBalancesStore = mockk<P2PBalancesStore>() private val p2PEthPoolBalancesStore = mockk<P2PEthPoolBalancesStore>()
private val dispatchers = TestingCoroutineDispatcherProvider() private val dispatchers = TestingCoroutineDispatcherProvider()
private val producer = DefaultMultiStakingBalanceProducer( private val producer = DefaultMultiStakingBalanceProducer(
params = params, params = params,
stakingBalancesStore = stakingBalancesStore, stakingBalancesStore = stakingBalancesStore,
p2pBalancesStore = p2pBalancesStore, p2PEthPoolBalancesStore = p2PEthPoolBalancesStore,
dispatchers = dispatchers, dispatchers = dispatchers,
) )
@ -48,13 +48,13 @@ internal class DefaultMultiStakingBalanceProducerTest {
val networksStatusesFlow = flowOf(balances) val networksStatusesFlow = flowOf(balances)
every { stakingBalancesStore.get(params.userWalletId) } returns networksStatusesFlow every { stakingBalancesStore.get(params.userWalletId) } returns networksStatusesFlow
every { p2pBalancesStore.get(params.userWalletId) } returns flowOf(emptySet()) every { p2PEthPoolBalancesStore.get(params.userWalletId) } returns flowOf(emptySet())
val actual = producer.produce() val actual = producer.produce()
// check after producer.produce() // check after producer.produce()
verify { stakingBalancesStore.get(params.userWalletId) } verify { stakingBalancesStore.get(params.userWalletId) }
verify { p2pBalancesStore.get(params.userWalletId) } verify { p2PEthPoolBalancesStore.get(params.userWalletId) }
val values = getEmittedValues(flow = actual) val values = getEmittedValues(flow = actual)
@ -67,13 +67,13 @@ internal class DefaultMultiStakingBalanceProducerTest {
val networksStatusesFlow = MutableSharedFlow<Set<StakingBalance>>(replay = 2) val networksStatusesFlow = MutableSharedFlow<Set<StakingBalance>>(replay = 2)
every { stakingBalancesStore.get(params.userWalletId) } returns networksStatusesFlow every { stakingBalancesStore.get(params.userWalletId) } returns networksStatusesFlow
every { p2pBalancesStore.get(params.userWalletId) } returns flowOf(emptySet()) every { p2PEthPoolBalancesStore.get(params.userWalletId) } returns flowOf(emptySet())
val actual = producer.produce() val actual = producer.produce()
// check after producer.produce() // check after producer.produce()
verify { stakingBalancesStore.get(params.userWalletId) } verify { stakingBalancesStore.get(params.userWalletId) }
verify { p2pBalancesStore.get(params.userWalletId) } verify { p2PEthPoolBalancesStore.get(params.userWalletId) }
// first emit // first emit
val balances = setOf( val balances = setOf(
@ -108,13 +108,13 @@ internal class DefaultMultiStakingBalanceProducerTest {
val networksStatusesFlow = MutableSharedFlow<Set<StakingBalance>>(replay = 2) val networksStatusesFlow = MutableSharedFlow<Set<StakingBalance>>(replay = 2)
every { stakingBalancesStore.get(params.userWalletId) } returns networksStatusesFlow every { stakingBalancesStore.get(params.userWalletId) } returns networksStatusesFlow
every { p2pBalancesStore.get(params.userWalletId) } returns flowOf(emptySet()) every { p2PEthPoolBalancesStore.get(params.userWalletId) } returns flowOf(emptySet())
val actual = producer.produce() val actual = producer.produce()
// check after producer.produce() // check after producer.produce()
verify { stakingBalancesStore.get(params.userWalletId) } verify { stakingBalancesStore.get(params.userWalletId) }
verify { p2pBalancesStore.get(params.userWalletId) } verify { p2PEthPoolBalancesStore.get(params.userWalletId) }
// first emit // first emit
val wrappers = setOf( val wrappers = setOf(
@ -157,13 +157,13 @@ internal class DefaultMultiStakingBalanceProducerTest {
.buffer(capacity = 5) .buffer(capacity = 5)
every { stakingBalancesStore.get(params.userWalletId) } returns networksStatusesFlow every { stakingBalancesStore.get(params.userWalletId) } returns networksStatusesFlow
every { p2pBalancesStore.get(params.userWalletId) } returns flowOf(emptySet()) every { p2PEthPoolBalancesStore.get(params.userWalletId) } returns flowOf(emptySet())
val actual = producer.produceWithFallback() val actual = producer.produceWithFallback()
// check after producer.produce() // check after producer.produce()
verify { stakingBalancesStore.get(params.userWalletId) } verify { stakingBalancesStore.get(params.userWalletId) }
verify { p2pBalancesStore.get(params.userWalletId) } verify { p2PEthPoolBalancesStore.get(params.userWalletId) }
val values1 = getEmittedValues(flow = actual) val values1 = getEmittedValues(flow = actual)
@ -181,13 +181,13 @@ internal class DefaultMultiStakingBalanceProducerTest {
@Test @Test
fun `test that flow is empty`() = runTest { fun `test that flow is empty`() = runTest {
every { stakingBalancesStore.get(params.userWalletId) } returns emptyFlow() every { stakingBalancesStore.get(params.userWalletId) } returns emptyFlow()
every { p2pBalancesStore.get(params.userWalletId) } returns emptyFlow() every { p2PEthPoolBalancesStore.get(params.userWalletId) } returns emptyFlow()
val actual = producer.produce() val actual = producer.produce()
// check after producer.produce() // check after producer.produce()
verify { stakingBalancesStore.get(params.userWalletId) } verify { stakingBalancesStore.get(params.userWalletId) }
verify { p2pBalancesStore.get(params.userWalletId) } verify { p2PEthPoolBalancesStore.get(params.userWalletId) }
val values = getEmittedValues(flow = actual) val values = getEmittedValues(flow = actual)
@ -198,53 +198,53 @@ internal class DefaultMultiStakingBalanceProducerTest {
@Test @Test
fun `test that StakeKit and P2P balances are combined`() = runTest { fun `test that StakeKit and P2P balances are combined`() = runTest {
val stakeKitBalances = createStakeKitBalances() val stakeKitBalances = createStakeKitBalances()
val p2pBalances = createP2PBalances() val p2pEthPoolBalances = createP2PEthPoolBalances()
every { stakingBalancesStore.get(params.userWalletId) } returns flowOf(stakeKitBalances) every { stakingBalancesStore.get(params.userWalletId) } returns flowOf(stakeKitBalances)
every { p2pBalancesStore.get(params.userWalletId) } returns flowOf(p2pBalances) every { p2PEthPoolBalancesStore.get(params.userWalletId) } returns flowOf(p2pEthPoolBalances)
val actual = producer.produce() val actual = producer.produce()
// check after producer.produce() // check after producer.produce()
verify { stakingBalancesStore.get(params.userWalletId) } verify { stakingBalancesStore.get(params.userWalletId) }
verify { p2pBalancesStore.get(params.userWalletId) } verify { p2PEthPoolBalancesStore.get(params.userWalletId) }
val values = getEmittedValues(flow = actual) val values = getEmittedValues(flow = actual)
Truth.assertThat(values.size).isEqualTo(1) Truth.assertThat(values.size).isEqualTo(1)
Truth.assertThat(values.first()).isEqualTo(stakeKitBalances + p2pBalances) Truth.assertThat(values.first()).isEqualTo(stakeKitBalances + p2pEthPoolBalances)
} }
@Test @Test
fun `test that P2P balances are updated independently from StakeKit`() = runTest { fun `test that P2P balances are updated independently from StakeKit`() = runTest {
val stakeKitBalances = createStakeKitBalancesWithTonOnly() val stakeKitBalances = createStakeKitBalancesWithTonOnly()
val p2pFlow = MutableSharedFlow<Set<StakingBalance>>(replay = 2) val p2pEthPoolFlow = MutableSharedFlow<Set<StakingBalance>>(replay = 2)
every { stakingBalancesStore.get(params.userWalletId) } returns flowOf(stakeKitBalances) every { stakingBalancesStore.get(params.userWalletId) } returns flowOf(stakeKitBalances)
every { p2pBalancesStore.get(params.userWalletId) } returns p2pFlow every { p2PEthPoolBalancesStore.get(params.userWalletId) } returns p2pEthPoolFlow
val actual = producer.produce() val actual = producer.produce()
// check after producer.produce() // check after producer.produce()
verify { stakingBalancesStore.get(params.userWalletId) } verify { stakingBalancesStore.get(params.userWalletId) }
verify { p2pBalancesStore.get(params.userWalletId) } verify { p2PEthPoolBalancesStore.get(params.userWalletId) }
// first emit - empty P2P // first emit - empty P2PEthPool
p2pFlow.emit(emptySet()) p2pEthPoolFlow.emit(emptySet())
val values1 = getEmittedValues(flow = actual) val values1 = getEmittedValues(flow = actual)
Truth.assertThat(values1.size).isEqualTo(1) Truth.assertThat(values1.size).isEqualTo(1)
Truth.assertThat(values1.first()).isEqualTo(stakeKitBalances) Truth.assertThat(values1.first()).isEqualTo(stakeKitBalances)
// second emit - with P2P balance // second emit - with P2PEthPool balance
val p2pBalances = createP2PBalances() val p2pEthPoolBalances = createP2PEthPoolBalances()
p2pFlow.emit(p2pBalances) p2pEthPoolFlow.emit(p2pEthPoolBalances)
val values2 = getEmittedValues(flow = actual) val values2 = getEmittedValues(flow = actual)
Truth.assertThat(values2.size).isEqualTo(2) Truth.assertThat(values2.size).isEqualTo(2)
Truth.assertThat(values2.last()).isEqualTo(stakeKitBalances + p2pBalances) Truth.assertThat(values2.last()).isEqualTo(stakeKitBalances + p2pEthPoolBalances)
} }
private companion object { private companion object {
@ -255,7 +255,7 @@ internal class DefaultMultiStakingBalanceProducerTest {
address = "0x1", address = "0x1",
) )
val p2pEthereumId = StakingID( val p2pEthereumId = StakingID(
integrationId = StakingIntegrationID.P2P.EthereumPooled.value, integrationId = StakingIntegrationID.P2PEthPool.value,
address = "0x5aa711F440Eb6d4361148bBD89d03464628ace84", address = "0x5aa711F440Eb6d4361148bBD89d03464628ace84",
) )
@ -272,7 +272,7 @@ internal class DefaultMultiStakingBalanceProducerTest {
) )
} }
fun createP2PBalances(): Set<StakingBalance> { fun createP2PEthPoolBalances(): Set<StakingBalance> {
return setOf( return setOf(
MockP2PEthPoolAccountResponseFactory.createWithBalance(stakingId = p2pEthereumId).toDomain( MockP2PEthPoolAccountResponseFactory.createWithBalance(stakingId = p2pEthereumId).toDomain(
source = StatusSource.ACTUAL, source = StatusSource.ACTUAL,

View file

@ -4,31 +4,31 @@ import com.tangem.domain.models.serialization.SerializedBigDecimal
import kotlinx.datetime.Instant import kotlinx.datetime.Instant
import kotlinx.serialization.Serializable import kotlinx.serialization.Serializable
/** P2P.org staking account */ /** P2P.org eth pooled staking account */
@Serializable @Serializable
data class P2PStakingAccount( data class P2PEthPoolStakingAccount(
val delegatorAddress: String, val delegatorAddress: String,
val vaultAddress: String, val vaultAddress: String,
val stake: P2PStake, val stake: P2PEthPoolStake,
val availableToUnstake: SerializedBigDecimal, val availableToUnstake: SerializedBigDecimal,
val availableToWithdraw: SerializedBigDecimal, val availableToWithdraw: SerializedBigDecimal,
val exitQueue: P2PExitQueue, val exitQueue: P2PEthPoolExitQueue,
) )
@Serializable @Serializable
data class P2PStake( data class P2PEthPoolStake(
val assets: SerializedBigDecimal, val assets: SerializedBigDecimal,
val totalEarnedAssets: SerializedBigDecimal, val totalEarnedAssets: SerializedBigDecimal,
) )
@Serializable @Serializable
data class P2PExitQueue( data class P2PEthPoolExitQueue(
val total: SerializedBigDecimal, val total: SerializedBigDecimal,
val requests: List<P2PExitRequest>, val requests: List<P2PEthPoolExitRequest>,
) )
@Serializable @Serializable
data class P2PExitRequest( data class P2PEthPoolExitRequest(
val ticket: String, val ticket: String,
val totalAssets: SerializedBigDecimal, val totalAssets: SerializedBigDecimal,
val timestamp: Instant, val timestamp: Instant,

View file

@ -5,7 +5,7 @@ import kotlinx.serialization.Serializable
import java.math.BigDecimal import java.math.BigDecimal
/** /**
* Staking balance facade covering StakeKit and P2P balances * Staking balance facade covering StakeKit and P2PEthPool balances
*/ */
@Serializable @Serializable
sealed interface StakingBalance { sealed interface StakingBalance {
@ -50,10 +50,10 @@ sealed interface StakingBalance {
} }
@Serializable @Serializable
data class P2P( data class P2PEthPool(
override val stakingId: StakingID, override val stakingId: StakingID,
override val source: StatusSource, override val source: StatusSource,
val account: P2PStakingAccount, val account: P2PEthPoolStakingAccount,
) : Data { ) : Data {
override val totalStaked: BigDecimal override val totalStaked: BigDecimal
@ -93,7 +93,7 @@ sealed interface StakingBalance {
fun copySealed(source: StatusSource): StakingBalance { fun copySealed(source: StatusSource): StakingBalance {
return when (this) { return when (this) {
is Data.StakeKit -> copy(source = source) is Data.StakeKit -> copy(source = source)
is Data.P2P -> copy(source = source) is Data.P2PEthPool -> copy(source = source)
is Empty -> copy(source = source) is Empty -> copy(source = source)
is Error -> this is Error -> this
} }

View file

@ -12,4 +12,17 @@ data class YieldToken(
val coinGeckoId: String?, val coinGeckoId: String?,
val logoURI: String?, val logoURI: String?,
val isPoints: Boolean?, val isPoints: Boolean?,
) ) {
companion object {
val ETH = YieldToken( // TODO p2p
name = "Ethereum",
network = NetworkType.ETHEREUM,
symbol = "ETH",
decimals = 18,
address = null,
coinGeckoId = "ethereum",
logoURI = null,
isPoints = false,
)
}
}

View file

@ -29,8 +29,6 @@ dependencies {
implementation(projects.domain.tokens.models) implementation(projects.domain.tokens.models)
implementation(projects.domain.wallets.models) implementation(projects.domain.wallets.models)
implementation(projects.features.staking.api)
implementation(tangemDeps.blockchain) { implementation(tangemDeps.blockchain) {
exclude(module = "joda-time") exclude(module = "joda-time")
} }

View file

@ -0,0 +1,66 @@
package com.tangem.domain.staking.model
import com.tangem.domain.staking.model.common.RewardInfo
import com.tangem.domain.staking.model.common.RewardType
import com.tangem.domain.staking.model.ethpool.P2PEthPoolVault
import com.tangem.domain.staking.model.stakekit.Yield
/**
* Represents either a StakeKit Validator or a P2P ETH Pool Vault.
*/
sealed interface StakingTarget {
/** Unique identifier (validator address or vault address) */
val address: String
/** Display name */
val name: String
/** Reward info (rate and type) */
val rewardInfo: RewardInfo?
/** Whether this target is preferred/recommended */
val isPreferred: Boolean
/** Whether this target is active and available for staking */
val isActive: Boolean
/** Image URL for display (validator logo or vault icon) */
val image: String?
/** Whether this is a strategic partner (shows special badge in UI) */
val isStrategicPartner: Boolean
/**
* StakeKit Validator wrapper
*/
data class Validator(val delegate: Yield.Validator) : StakingTarget {
override val address: String = delegate.address
override val name: String = delegate.name
override val rewardInfo: RewardInfo? = delegate.rewardInfo
override val isPreferred: Boolean = delegate.preferred
override val isActive: Boolean = delegate.status == Yield.Validator.ValidatorStatus.ACTIVE
override val image: String? = delegate.image
override val isStrategicPartner: Boolean = delegate.isStrategicPartner
}
/**
* P2P ETH Pool Vault wrapper
*/
data class Vault(val vault: P2PEthPoolVault) : StakingTarget {
override val address: String = vault.vaultAddress
override val name: String = vault.displayName
override val rewardInfo = RewardInfo(
rate = vault.apy,
type = RewardType.APY,
)
override val isPreferred: Boolean = true
override val isActive: Boolean = true
override val image: String? = null
override val isStrategicPartner: Boolean = true
}
}
fun Yield.Validator.toStakingTarget(): StakingTarget = StakingTarget.Validator(this)
fun P2PEthPoolVault.toStakingTarget(): StakingTarget = StakingTarget.Vault(this)

View file

@ -0,0 +1,10 @@
package com.tangem.domain.staking.model.common
import kotlinx.serialization.Serializable
@Serializable
enum class RewardClaiming {
AUTO,
MANUAL,
UNKNOWN,
}

View file

@ -0,0 +1,10 @@
package com.tangem.domain.staking.model.common
import com.tangem.domain.models.serialization.SerializedBigDecimal
import kotlinx.serialization.Serializable
@Serializable
data class RewardInfo(
val rate: SerializedBigDecimal,
val type: RewardType,
)

View file

@ -0,0 +1,15 @@
package com.tangem.domain.staking.model.common
import kotlinx.serialization.Serializable
@Serializable
enum class RewardSchedule {
BLOCK,
HOUR,
DAY,
WEEK,
MONTH,
ERA,
EPOCH,
UNKNOWN,
}

View file

@ -0,0 +1,10 @@
package com.tangem.domain.staking.model.common
import kotlinx.serialization.Serializable
@Serializable
enum class RewardType {
APY, // compound rate
APR, // simple rate
UNKNOWN,
}

View file

@ -0,0 +1,17 @@
package com.tangem.domain.staking.model.common
import kotlinx.serialization.Serializable
/**
* Provider-agnostic representation of staking action arguments.
* Contains amount requirements and constraints for enter/exit operations.
*
* Maps from:
* - StakeKit: Yield.Args.Enter
* - P2PEthPool: P2PEthPoolStaking.Metadata
*/
@Serializable
data class StakingActionArgs(
val amountRequirement: StakingAmountRequirement?,
val isPartialAmountDisabled: Boolean,
)

View file

@ -0,0 +1,19 @@
package com.tangem.domain.staking.model.common
import com.tangem.domain.models.serialization.SerializedBigDecimal
import kotlinx.serialization.Serializable
/**
* Provider-agnostic representation of staking amount requirements.
* Contains validation constraints for stake/unstake amounts.
*
* Maps from:
* - StakeKit: AddressArgument with ArgType.AMOUNT
* - P2PEthPool: P2PEthPoolStaking.Metadata minimumStake/maximumStake
*/
@Serializable
data class StakingAmountRequirement(
val isRequired: Boolean,
val minimum: SerializedBigDecimal? = null,
val maximum: SerializedBigDecimal? = null,
)

View file

@ -4,7 +4,7 @@ import com.tangem.domain.models.serialization.SerializedBigDecimal
import org.joda.time.Instant import org.joda.time.Instant
/** /**
* P2P.org account staking information * Account staking information
* Contains detailed balance and exit queue information * Contains detailed balance and exit queue information
*/ */
data class P2PEthPoolAccount( data class P2PEthPoolAccount(

View file

@ -22,7 +22,7 @@ data class P2PEthPoolAction(
) )
/** /**
* Types of P2P staking actions * Types of P2PEthPool staking actions
*/ */
@Serializable @Serializable
enum class P2PEthPoolActionType { enum class P2PEthPoolActionType {
@ -33,7 +33,7 @@ enum class P2PEthPoolActionType {
} }
/** /**
* Status of P2P staking action * Status of P2PEthPool staking action
*/ */
@Serializable @Serializable
enum class P2PEthPoolActionStatus { enum class P2PEthPoolActionStatus {
@ -46,7 +46,7 @@ enum class P2PEthPoolActionStatus {
} }
/** /**
* Transaction details for P2P staking action * Transaction details for P2PEthPool staking action
*/ */
data class P2PEthPoolStakingTransaction( data class P2PEthPoolStakingTransaction(
val id: String?, val id: String?,

View file

@ -4,7 +4,7 @@ import com.tangem.domain.models.serialization.SerializedBigDecimal
import org.joda.time.Instant import org.joda.time.Instant
/** /**
* P2P.org staking balance information (similar to StakeKit's YieldBalanceItem) * Staking balance information (similar to StakeKit's YieldBalanceItem)
* Contains staked amounts, rewards, and pending actions * Contains staked amounts, rewards, and pending actions
*/ */
data class P2PEthPoolStakingBalance( data class P2PEthPoolStakingBalance(
@ -15,7 +15,7 @@ data class P2PEthPoolStakingBalance(
) )
/** /**
* Individual balance item for P2P staking * Individual balance item for P2PEthPool staking
*/ */
data class P2PEthPoolBalanceItem( data class P2PEthPoolBalanceItem(
val type: P2PEthPoolBalanceType, val type: P2PEthPoolBalanceType,
@ -26,7 +26,7 @@ data class P2PEthPoolBalanceItem(
) )
/** /**
* Types of balances in P2P staking * Types of balances in P2PEthPool staking
*/ */
enum class P2PEthPoolBalanceType { enum class P2PEthPoolBalanceType {
STAKED, STAKED,

View file

@ -61,7 +61,7 @@ enum class P2PEthPoolNetwork(
} }
/** /**
* Check if chain ID is supported for P2P staking * Check if chain ID is supported for P2PEthPool staking
* *
* @param chainId Ethereum chain ID * @param chainId Ethereum chain ID
* @return true if supported, false otherwise * @return true if supported, false otherwise

View file

@ -4,7 +4,7 @@ import com.tangem.domain.models.serialization.SerializedBigDecimal
import org.joda.time.DateTime import org.joda.time.DateTime
/** /**
* P2P.org rewards history entry * Rewards history entry
* Historical reward information for account * Historical reward information for account
*/ */
data class P2PEthPoolReward( data class P2PEthPoolReward(

View file

@ -61,7 +61,7 @@ data class P2PEthPoolStaking(
} }
/** /**
* Detailed vault information for P2P staking * Detailed vault information for P2PEthPool staking
*/ */
data class P2PEthPoolVaultDetails( data class P2PEthPoolVaultDetails(
val vaultAddress: String, val vaultAddress: String,

View file

@ -1,11 +1,11 @@
package com.tangem.domain.staking.model.ethpool package com.tangem.domain.staking.model.ethpool
/** /**
* Configuration for P2P Ethereum staking network. * Configuration for P2PEthPool Ethereum staking network.
* *
* Change [USE_TESTNET] to switch between testnet and mainnet. * Change [USE_TESTNET] to switch between testnet and mainnet.
*/ */
object P2PStakingConfig { object P2PEthPoolStakingConfig {
const val USE_TESTNET: Boolean = true const val USE_TESTNET: Boolean = true

View file

@ -3,7 +3,7 @@ package com.tangem.domain.staking.model.ethpool
import com.tangem.domain.models.serialization.SerializedBigDecimal import com.tangem.domain.models.serialization.SerializedBigDecimal
/** /**
* P2P.org unsigned transaction ready for signing * Unsigned transaction ready for signing
* Contains all necessary data for transaction signing * Contains all necessary data for transaction signing
*/ */
data class P2PEthPoolUnsignedTx( data class P2PEthPoolUnsignedTx(

View file

@ -2,6 +2,8 @@ package com.tangem.domain.staking.model.stakekit
import com.tangem.domain.models.serialization.SerializedBigDecimal import com.tangem.domain.models.serialization.SerializedBigDecimal
import com.tangem.domain.models.staking.YieldToken import com.tangem.domain.models.staking.YieldToken
import com.tangem.domain.staking.model.common.RewardInfo
import com.tangem.domain.staking.model.common.RewardType
import kotlinx.serialization.Serializable import kotlinx.serialization.Serializable
@Serializable @Serializable
@ -138,18 +140,6 @@ data class Yield(
UNKNOWN, UNKNOWN,
} }
} }
enum class RewardType {
APY, // compound rate
APR, // simple rate
UNKNOWN,
}
@Serializable
data class RewardInfo(
val rate: SerializedBigDecimal,
val type: RewardType,
)
} }
@Serializable @Serializable

View file

@ -12,11 +12,11 @@ import kotlinx.coroutines.launch
/** /**
* Use case for fetching all staking options from all providers * Use case for fetching all staking options from all providers
* Fetches both StakeKit yields and P2P vaults * Fetches both StakeKit yields and P2PEthPool vaults
*/ */
class FetchStakingOptionsUseCase( class FetchStakingOptionsUseCase(
private val stakeKitRepository: StakeKitRepository, private val stakeKitRepository: StakeKitRepository,
private val p2pRepository: P2PEthPoolRepository, private val p2pEthPoolRepository: P2PEthPoolRepository,
private val stakingErrorResolver: StakingErrorResolver, private val stakingErrorResolver: StakingErrorResolver,
) { ) {
suspend operator fun invoke(): Either<StakingError, Unit> { suspend operator fun invoke(): Either<StakingError, Unit> {
@ -25,7 +25,7 @@ class FetchStakingOptionsUseCase(
block = { block = {
coroutineScope { coroutineScope {
launch { stakeKitRepository.fetchYields() } launch { stakeKitRepository.fetchYields() }
launch { p2pRepository.fetchVaults() } launch { p2pEthPoolRepository.fetchVaults() }
} }
}, },
catch = { stakingErrorResolver.resolve(it) }, catch = { stakingErrorResolver.resolve(it) },

View file

@ -30,7 +30,7 @@ class GetStakingEntryInfoUseCase(
symbol = symbol, symbol = symbol,
) )
} }
is StakingOption.P2P -> { is StakingOption.P2PEthPool -> {
StakingEntryInfo( StakingEntryInfo(
tokenSymbol = "ETH", tokenSymbol = "ETH",
) )

View file

@ -0,0 +1,79 @@
package com.tangem.domain.staking.model
import com.tangem.domain.models.currency.CryptoCurrency
import com.tangem.domain.models.staking.YieldToken
import com.tangem.domain.staking.model.common.RewardClaiming
import com.tangem.domain.staking.model.common.RewardSchedule
import com.tangem.domain.staking.model.common.StakingActionArgs
import com.tangem.domain.staking.model.common.StakingAmountRequirement
import com.tangem.domain.staking.model.ethpool.P2PEthPoolVault
import java.math.BigDecimal
/**
* StakingIntegration implementation for P2PEthPool pooled staking.
* Converts P2PEthPoolVault data to the common StakingIntegration interface.
*/
class P2PEthPoolIntegration(
override val integrationId: StakingIntegrationID,
vaults: List<P2PEthPoolVault>,
) : StakingIntegration {
// Basic
override val token: YieldToken = YieldToken.ETH
override val tokens: List<YieldToken> = listOf(token)
// Targets (vaults)
override val targets: List<StakingTarget> = vaults.map { vault ->
vault.toStakingTarget()
}
override val preferredTargets: List<StakingTarget> = targets
override val areAllTargetsFull: Boolean = false
// Enter/Exit Args
override val isPartialAmountDisabled: Boolean = false
override val enterMinimumAmount: BigDecimal = DEFAULT_MINIMUM_STAKE
override val exitMinimumAmount: BigDecimal? = null
override val enterArgs: StakingActionArgs = StakingActionArgs(
amountRequirement = StakingAmountRequirement(
isRequired = true,
minimum = DEFAULT_MINIMUM_STAKE,
maximum = null,
),
isPartialAmountDisabled = false,
)
override val exitArgs: StakingActionArgs = StakingActionArgs(
amountRequirement = StakingAmountRequirement(
isRequired = true,
minimum = null,
maximum = null,
),
isPartialAmountDisabled = false,
)
// Metadata
override val warmupPeriodDays: Int = 0
override val cooldownPeriodDays: Int = DEFAULT_COOLDOWN_DAYS
override val rewardSchedule: RewardSchedule = RewardSchedule.DAY
override val rewardClaiming: RewardClaiming = RewardClaiming.AUTO
override fun getCurrentToken(rawCurrencyId: CryptoCurrency.RawID?): YieldToken = token
companion object {
private const val DEFAULT_COOLDOWN_DAYS = 7
private val DEFAULT_MINIMUM_STAKE = BigDecimal("0.01")
}
}

View file

@ -0,0 +1,99 @@
package com.tangem.domain.staking.model
import com.tangem.domain.models.currency.CryptoCurrency
import com.tangem.domain.models.staking.YieldToken
import com.tangem.domain.staking.model.common.RewardClaiming
import com.tangem.domain.staking.model.common.RewardSchedule
import com.tangem.domain.staking.model.common.StakingActionArgs
import com.tangem.domain.staking.model.common.StakingAmountRequirement
import com.tangem.domain.staking.model.stakekit.Yield
import java.math.BigDecimal
/**
* StakingIntegration implementation for StakeKit.
* Delegates all calls to the underlying Yield object.
*/
class StakeKitIntegration(
override val integrationId: StakingIntegrationID,
private val yield: Yield,
) : StakingIntegration {
// Basic
override val token: YieldToken = yield.token
override val tokens: List<YieldToken> = yield.tokens
// Targets (validators)
override val targets: List<StakingTarget> = yield.validators.map { it.toStakingTarget() }
override val preferredTargets: List<StakingTarget> = yield.preferredValidators.map { it.toStakingTarget() }
override val areAllTargetsFull: Boolean = yield.allValidatorsFull
// Enter/Exit Args
override val isPartialAmountDisabled: Boolean = yield.args.enter.isPartialAmountDisabled
override val enterMinimumAmount: BigDecimal? =
yield.args.enter.args[Yield.Args.ArgType.AMOUNT]?.minimum
override val exitMinimumAmount: BigDecimal? =
yield.args.exit?.args
?.get(Yield.Args.ArgType.AMOUNT)?.minimum
override val enterArgs: StakingActionArgs = yield.args.enter.toStakingActionArgs()
override val exitArgs: StakingActionArgs? = yield.args.exit?.toStakingActionArgs()
// Metadata
override val warmupPeriodDays: Int = yield.metadata.warmupPeriod.days
override val cooldownPeriodDays: Int? = yield.metadata.cooldownPeriod?.days
override val rewardSchedule: RewardSchedule = yield.metadata.rewardSchedule.toRewardSchedule()
override val rewardClaiming: RewardClaiming = yield.metadata.rewardClaiming.toRewardClaiming()
// Basic
override fun getCurrentToken(rawCurrencyId: CryptoCurrency.RawID?): YieldToken =
tokens.firstOrNull { rawCurrencyId?.value == it.coinGeckoId } ?: token
private fun Yield.Args.Enter.toStakingActionArgs(): StakingActionArgs {
val amountArg = args[Yield.Args.ArgType.AMOUNT]
return StakingActionArgs(
amountRequirement = amountArg?.let { arg ->
StakingAmountRequirement(
isRequired = arg.required,
minimum = arg.minimum,
maximum = arg.maximum,
)
},
isPartialAmountDisabled = isPartialAmountDisabled,
)
}
private fun Yield.Metadata.RewardSchedule.toRewardSchedule(): RewardSchedule {
return when (this) {
Yield.Metadata.RewardSchedule.BLOCK -> RewardSchedule.BLOCK
Yield.Metadata.RewardSchedule.HOUR -> RewardSchedule.HOUR
Yield.Metadata.RewardSchedule.DAY -> RewardSchedule.DAY
Yield.Metadata.RewardSchedule.WEEK -> RewardSchedule.WEEK
Yield.Metadata.RewardSchedule.MONTH -> RewardSchedule.MONTH
Yield.Metadata.RewardSchedule.ERA -> RewardSchedule.ERA
Yield.Metadata.RewardSchedule.EPOCH -> RewardSchedule.EPOCH
Yield.Metadata.RewardSchedule.UNKNOWN -> RewardSchedule.UNKNOWN
}
}
private fun Yield.Metadata.RewardClaiming.toRewardClaiming(): RewardClaiming {
return when (this) {
Yield.Metadata.RewardClaiming.AUTO -> RewardClaiming.AUTO
Yield.Metadata.RewardClaiming.MANUAL -> RewardClaiming.MANUAL
Yield.Metadata.RewardClaiming.UNKNOWN -> RewardClaiming.UNKNOWN
}
}
}

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