Updated on 2026-08-14

This commit is contained in:
Tangem 2026-06-15 16:30:38 +04:00
parent 7b4593acee
commit 223051955f
22 changed files with 606 additions and 95 deletions

View file

@ -48,6 +48,7 @@ internal class TokenActionsModel @Inject constructor(
onHandleQuickAction = { handledAction, shouldDismiss ->
handledQuickAction(handledAction, shouldDismiss)
},
coroutineScope = modelScope,
)
val bottomSheetNavigation: SlotNavigation<TokenReceiveConfig> = SlotNavigation()

View file

@ -151,6 +151,7 @@ internal class MarketsPortfolioModel @Inject constructor(
)
configureReceiveAddresses(handledAction)
},
coroutineScope = modelScope,
)
}

View file

@ -121,6 +121,7 @@ internal class OnrampOperationModel @Inject constructor(
.getOrElse { AppCurrency.Default }.code
getOfframpUrlUseCase(
userWalletId = selectedUserWallet.walletId,
cryptoCurrencyStatus = status,
appCurrencyCode = appCurrencyCode,
).onRight { url ->

View file

@ -42,6 +42,7 @@ dependencies {
/** Domain */
implementation(projects.domain.models)
implementation(projects.domain.legacy)
implementation(projects.domain.offramp)
implementation(projects.domain.card)
implementation(projects.domain.tokens.models)
implementation(projects.domain.tokens)

View file

@ -11,6 +11,7 @@ import com.tangem.domain.account.status.utils.CryptoCurrencyOperations.getCrypto
import com.tangem.domain.account.supplier.SingleAccountListSupplier
import com.tangem.domain.models.currency.CryptoCurrency
import com.tangem.domain.models.wallet.UserWalletId
import com.tangem.domain.offramp.repository.OfframpRepository
import com.tangem.domain.wallets.usecase.GetSelectedWalletSyncUseCase
import com.tangem.features.send.api.deeplink.SellRedirectDeepLinkHandler
import dagger.assisted.Assisted
@ -20,13 +21,14 @@ import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.launch
import com.tangem.utils.logging.TangemLogger
@Suppress("ComplexCondition")
@Suppress("ComplexCondition", "LongParameterList")
internal class DefaultSellRedirectDeepLinkHandler @AssistedInject constructor(
@Assisted scope: CoroutineScope,
@Assisted queryParams: Map<String, String>,
appRouter: AppRouter,
getSelectedWalletSyncUseCase: GetSelectedWalletSyncUseCase,
private val singleAccountListSupplier: SingleAccountListSupplier,
private val offrampRepository: OfframpRepository,
) : SellRedirectDeepLinkHandler {
init {
@ -35,6 +37,7 @@ internal class DefaultSellRedirectDeepLinkHandler @AssistedInject constructor(
val amount = queryParams[AMOUNT_KEY]
val destinationAddress = queryParams[DESTINATION_ADDRESS_KEY]
val memo = queryParams[MEMO_KEY]
val requestId = queryParams[REQUEST_ID_KEY]
// It is okay here, we are navigating from outside, and there is no other way to getting UserWallet
getSelectedWalletSyncUseCase()
@ -44,20 +47,30 @@ internal class DefaultSellRedirectDeepLinkHandler @AssistedInject constructor(
},
ifRight = { userWallet ->
if (currencyId.isNullOrEmpty() || transactionId.isNullOrEmpty() ||
amount.isNullOrEmpty() || destinationAddress.isNullOrEmpty()
amount.isNullOrEmpty() || destinationAddress.isNullOrEmpty() ||
requestId.isNullOrEmpty()
) {
TangemLogger.e(
"""
Invalid parameters for SELL deeplink
|- Params: $queryParams
""".trimIndent(),
)
// Do not log the params: they contain the deposit address and request_id.
TangemLogger.e("Invalid parameters for SELL deeplink")
return@fold
}
scope.launch {
// Only trust the redirect if it carries a request_id we issued for a sell this
// app actually started (single-use, bound to the wallet + currency). Otherwise an external
// deeplink could inject a locked attacker recipient/amount into the Send confirm screen.
val pendingOfframp = offrampRepository.consumePendingOfframp(
requestId = requestId,
userWalletId = userWallet.walletId,
currencyId = currencyId,
)
if (pendingOfframp == null) {
TangemLogger.e("Rejected SELL deeplink: no matching app-initiated sell")
return@launch
}
val cryptoCurrency = getCryptoCurrency(userWallet.walletId, currencyId).getOrElse {
TangemLogger.e("Error on getting cryptoCurrency: $currencyId")
TangemLogger.e("Error on getting cryptoCurrency for SELL deeplink")
return@launch
}
// Convert using universal parser to account for regional separators
@ -100,5 +113,6 @@ internal class DefaultSellRedirectDeepLinkHandler @AssistedInject constructor(
const val AMOUNT_KEY = "baseCurrencyAmount"
const val DESTINATION_ADDRESS_KEY = "depositWalletAddress"
const val MEMO_KEY = "depositWalletAddressTag"
const val REQUEST_ID_KEY = "request_id"
}
}

View file

@ -0,0 +1,103 @@
package com.tangem.features.send.deeplink
import arrow.core.right
import com.tangem.common.routing.AppRouter
import com.tangem.domain.account.supplier.SingleAccountListSupplier
import com.tangem.domain.models.wallet.UserWallet
import com.tangem.domain.models.wallet.UserWalletId
import com.tangem.domain.offramp.model.PendingOfframp
import com.tangem.domain.offramp.repository.OfframpRepository
import com.tangem.domain.wallets.usecase.GetSelectedWalletSyncUseCase
import io.mockk.clearMocks
import io.mockk.coEvery
import io.mockk.coVerify
import io.mockk.every
import io.mockk.mockk
import io.mockk.verify
import kotlinx.coroutines.test.TestScope
import kotlinx.coroutines.test.advanceUntilIdle
import kotlinx.coroutines.test.runTest
import org.junit.jupiter.api.BeforeEach
import org.junit.jupiter.api.Test
internal class DefaultSellRedirectDeepLinkHandlerTest {
private val appRouter: AppRouter = mockk(relaxed = true)
private val getSelectedWalletSyncUseCase: GetSelectedWalletSyncUseCase = mockk()
private val singleAccountListSupplier: SingleAccountListSupplier = mockk()
private val offrampRepository: OfframpRepository = mockk()
private val userWalletId = UserWalletId("0011223344556677")
private val currencyId = "bitcoin"
private val requestId = "request-id-001"
private val userWallet: UserWallet = mockk { every { walletId } returns userWalletId }
@BeforeEach
fun setup() {
clearMocks(appRouter, getSelectedWalletSyncUseCase, singleAccountListSupplier, offrampRepository)
every { getSelectedWalletSyncUseCase() } returns userWallet.right()
// Returning null here means the (legitimate) currency lookup yields nothing, so a passed gate stops before
// navigation. We assert the gate via whether the currency lookup is reached at all.
coEvery { singleAccountListSupplier.getSyncOrNull(any<UserWalletId>()) } returns null
}
@Test
fun `GIVEN matching pending offramp WHEN deeplink handled THEN request passes the gate`() = runTest {
coEvery { offrampRepository.consumePendingOfframp(requestId, userWalletId, currencyId) } returns pendingOfframp()
createHandler(validParams())
advanceUntilIdle()
coVerify(exactly = 1) { offrampRepository.consumePendingOfframp(requestId, userWalletId, currencyId) }
coVerify(exactly = 1) { singleAccountListSupplier.getSyncOrNull(userWalletId) }
}
@Test
fun `GIVEN no request_id WHEN deeplink handled THEN rejected without touching the store`() = runTest {
createHandler(validParams() - REQUEST_ID_KEY)
advanceUntilIdle()
coVerify(exactly = 0) { offrampRepository.consumePendingOfframp(any(), any(), any()) }
coVerify(exactly = 0) { singleAccountListSupplier.getSyncOrNull(any<UserWalletId>()) }
verify(exactly = 0) { appRouter.push(any()) }
}
@Test
fun `GIVEN no matching pending offramp WHEN deeplink handled THEN rejected`() = runTest {
coEvery { offrampRepository.consumePendingOfframp(requestId, userWalletId, currencyId) } returns null
createHandler(validParams())
advanceUntilIdle()
coVerify(exactly = 0) { singleAccountListSupplier.getSyncOrNull(any<UserWalletId>()) }
verify(exactly = 0) { appRouter.push(any()) }
}
private fun TestScope.createHandler(queryParams: Map<String, String>) = DefaultSellRedirectDeepLinkHandler(
scope = this,
queryParams = queryParams,
appRouter = appRouter,
getSelectedWalletSyncUseCase = getSelectedWalletSyncUseCase,
singleAccountListSupplier = singleAccountListSupplier,
offrampRepository = offrampRepository,
)
private fun pendingOfframp() = PendingOfframp(
requestId = requestId,
userWalletId = userWalletId,
currencyId = currencyId,
createdAt = 0L,
)
private fun validParams() = mapOf(
"currency_id" to currencyId,
"transactionId" to "tx-001",
"baseCurrencyAmount" to "1.5",
"depositWalletAddress" to "depositAddress",
REQUEST_ID_KEY to requestId,
)
private companion object {
const val REQUEST_ID_KEY = "request_id"
}
}

View file

@ -783,12 +783,15 @@ internal class TokenDetailsModel @Inject constructor(
showErrorIfDemoModeOrElse {
val status = cryptoCurrencyStatus ?: return@showErrorIfDemoModeOrElse
getOfframpUrlUseCase(
cryptoCurrencyStatus = status,
appCurrencyCode = selectedAppCurrencyFlow.value.code,
).onRight { url ->
urlOpener.openUrl(url)
analyticsEventsHandler.send(OfframpAnalyticsEvent.ScreenOpened)
modelScope.launch {
getOfframpUrlUseCase(
userWalletId = userWalletId,
cryptoCurrencyStatus = status,
appCurrencyCode = selectedAppCurrencyFlow.value.code,
).onRight { url ->
urlOpener.openUrl(url)
analyticsEventsHandler.send(OfframpAnalyticsEvent.ScreenOpened)
}
}
}
}

View file

@ -41,9 +41,9 @@ import com.tangem.domain.models.wallet.UserWallet
import com.tangem.domain.models.wallet.UserWalletId
import com.tangem.domain.offramp.GetOfframpUrlUseCase
import com.tangem.domain.onramp.model.OnrampSource
import com.tangem.domain.staking.model.StakingOption
import com.tangem.domain.stories.GetStoryContentUseCase
import com.tangem.domain.stories.models.StoryContentIds
import com.tangem.domain.staking.model.StakingOption
import com.tangem.domain.tokens.NeedShowYieldSupplyDepositedWarningUseCase
import com.tangem.domain.tokens.SaveViewedTokenReceiveWarningUseCase
import com.tangem.domain.tokens.SaveViewedYieldSupplyWarningUseCase
@ -61,12 +61,7 @@ import com.tangem.domain.wallets.usecase.GetSelectedWalletSyncUseCase
import com.tangem.feature.wallet.impl.R
import com.tangem.feature.wallet.presentation.wallet.domain.unwrap
import com.tangem.feature.wallet.presentation.wallet.state.WalletStateController
import com.tangem.feature.wallet.presentation.wallet.state.model.WalletAlertUM
import com.tangem.feature.wallet.presentation.wallet.state.model.WalletEvent
import com.tangem.feature.wallet.presentation.wallet.state.model.WalletState
import com.tangem.feature.wallet.presentation.wallet.state.model.WalletTokensListState
import com.tangem.feature.wallet.presentation.wallet.state.model.WalletTokensListUM
import com.tangem.feature.wallet.presentation.wallet.state.model.WalletUM
import com.tangem.feature.wallet.presentation.wallet.state.model.*
import com.tangem.feature.wallet.presentation.wallet.state.transformers.CloseBottomSheetTransformer
import com.tangem.feature.wallet.presentation.wallet.state.utils.WalletEventSender
import com.tangem.utils.coroutines.CoroutineDispatcherProvider
@ -316,9 +311,10 @@ internal class WalletCurrencyActionsClickIntentsImplementor @Inject constructor(
if (handleUnavailabilityReason(unavailabilityReason)) return
showErrorIfDemoModeOrElse {
showErrorIfDemoModeOrElse { userWallet ->
modelScope.launch(dispatchers.main) {
getOfframpUrlUseCase(
userWalletId = userWallet.walletId,
cryptoCurrencyStatus = cryptoCurrencyStatus,
appCurrencyCode = getSelectedAppCurrencyUseCase.unwrap().code,
).onRight { url ->
@ -480,8 +476,8 @@ internal class WalletCurrencyActionsClickIntentsImplementor @Inject constructor(
)
}
private fun openExplorer() {
val userWalletId = stateHolder.getSelectedWalletId()
private fun openExplorer(userWallet: UserWallet) {
val userWalletId = userWallet.walletId
modelScope.launch(dispatchers.main) {
val currencyStatus = singleAccountStatusListSupplier.unwrap(userWalletId) ?: return@launch
@ -545,7 +541,7 @@ internal class WalletCurrencyActionsClickIntentsImplementor @Inject constructor(
}
}
private fun showErrorIfDemoModeOrElse(action: () -> Unit) {
private fun showErrorIfDemoModeOrElse(action: (UserWallet) -> Unit) {
val selectedWallet = getSelectedWalletSyncUseCase.unwrap() ?: return
if (selectedWallet is UserWallet.Cold && isDemoCardUseCase(cardId = selectedWallet.cardId)) {
@ -557,7 +553,7 @@ internal class WalletCurrencyActionsClickIntentsImplementor @Inject constructor(
),
)
} else {
action()
action(selectedWallet)
}
}