From 223051955faf45b8e637ac99a0deeca4e16ea938 Mon Sep 17 00:00:00 2001 From: Tangem Date: Mon, 15 Jun 2026 16:30:38 +0400 Subject: [PATCH] Updated on 2026-08-14 --- .../tap/data/DefaultOfframpRepository.kt | 62 ++++++++- .../converter/PendingOfframpEntryConverter.kt | 19 +++ .../tap/data/model/PendingOfframpEntry.kt | 23 +++ .../tap/di/domain/OfframpDomainModule.kt | 56 ++++++++ .../tap/di/domain/OnrampDomainModule.kt | 16 --- .../network/exchangeServices/SellService.kt | 1 + .../moonpay/MoonPayService.kt | 8 +- .../tap/data/DefaultOfframpRepositoryTest.kt | 131 ++++++++++++++++-- .../PendingOfframpEntryConverterTest.kt | 55 ++++++++ .../ui/markets/action/TokenActionsHandler.kt | 19 ++- .../domain/offramp/GetOfframpUrlUseCase.kt | 41 ++++-- .../domain/offramp/model/PendingOfframp.kt | 22 +++ .../offramp/repository/OfframpRepository.kt | 28 +++- .../offramp/GetOfframpUrlUseCaseTest.kt | 46 +++--- .../tokenactions/model/TokenActionsModel.kt | 1 + .../impl/model/MarketsPortfolioModel.kt | 1 + .../selecttoken/model/OnrampOperationModel.kt | 1 + features/send/impl/build.gradle.kts | 1 + .../DefaultSellRedirectDeepLinkHandler.kt | 32 +++-- .../DefaultSellRedirectDeepLinkHandlerTest.kt | 103 ++++++++++++++ .../tokendetails/model/TokenDetailsModel.kt | 15 +- .../WalletCurrencyActionsClickIntents.kt | 20 ++- 22 files changed, 606 insertions(+), 95 deletions(-) create mode 100644 app/src/main/java/com/tangem/tap/data/converter/PendingOfframpEntryConverter.kt create mode 100644 app/src/main/java/com/tangem/tap/data/model/PendingOfframpEntry.kt create mode 100644 app/src/main/java/com/tangem/tap/di/domain/OfframpDomainModule.kt create mode 100644 app/src/test/kotlin/com/tangem/tap/data/converter/PendingOfframpEntryConverterTest.kt create mode 100644 domain/offramp/src/main/java/com/tangem/domain/offramp/model/PendingOfframp.kt create mode 100644 features/send/impl/src/test/java/com/tangem/features/send/deeplink/DefaultSellRedirectDeepLinkHandlerTest.kt diff --git a/app/src/main/java/com/tangem/tap/data/DefaultOfframpRepository.kt b/app/src/main/java/com/tangem/tap/data/DefaultOfframpRepository.kt index 89c89aef25..7050229bd8 100644 --- a/app/src/main/java/com/tangem/tap/data/DefaultOfframpRepository.kt +++ b/app/src/main/java/com/tangem/tap/data/DefaultOfframpRepository.kt @@ -1,29 +1,89 @@ package com.tangem.tap.data +import androidx.datastore.core.DataStore import com.tangem.domain.models.currency.CryptoCurrency +import com.tangem.domain.models.wallet.UserWalletId +import com.tangem.domain.offramp.model.PendingOfframp import com.tangem.domain.offramp.repository.OfframpRepository import com.tangem.tap.common.apptheme.MutableAppThemeModeHolder +import com.tangem.tap.data.converter.PendingOfframpEntryConverter +import com.tangem.tap.data.model.PendingOfframpEntry import com.tangem.tap.network.exchangeServices.SellService +import com.tangem.utils.coroutines.CoroutineDispatcherProvider +import kotlinx.coroutines.withContext +import java.util.UUID +import java.util.concurrent.TimeUnit /** - * Default implementation of [OfframpRepository] + * Default implementation of [OfframpRepository]. * * @property sellService sell service for getting offramp URL + * @property pendingOfframpStore dedicated kotlinx-serialized store of app-initiated sells + * @property dispatchers coroutine dispatchers provider for IO operations */ internal class DefaultOfframpRepository( private val sellService: SellService, + private val pendingOfframpStore: DataStore>, + private val dispatchers: CoroutineDispatcherProvider, ) : OfframpRepository { + private val pendingOfframpConverter = PendingOfframpEntryConverter() + override fun getOfframpUrl( cryptoCurrency: CryptoCurrency, fiatCurrencyCode: String, walletAddress: String, + requestId: String, ): String? { return sellService.getUrl( cryptoCurrency = cryptoCurrency, fiatCurrencyName = fiatCurrencyCode, walletAddress = walletAddress, isDarkTheme = MutableAppThemeModeHolder.isDarkThemeActive, + requestId = requestId, ) } + + override suspend fun registerPendingOfframp(userWalletId: UserWalletId, currencyId: String): String = + withContext(dispatchers.io) { + val requestId = UUID.randomUUID().toString() + val now = System.currentTimeMillis() + pendingOfframpStore.updateData { stored -> + stored.filterNotExpired(now) + PendingOfframpEntry( + requestId = requestId, + userWalletId = userWalletId.stringValue, + currencyId = currencyId, + createdAt = now, + ) + } + requestId + } + + override suspend fun consumePendingOfframp( + requestId: String, + userWalletId: UserWalletId, + currencyId: String, + ): PendingOfframp? = withContext(dispatchers.io) { + val now = System.currentTimeMillis() + var matched: PendingOfframpEntry? = null + pendingOfframpStore.updateData { stored -> + matched = stored.firstOrNull { entry -> + entry.requestId == requestId && + entry.userWalletId == userWalletId.stringValue && + entry.currencyId == currencyId && + now - entry.createdAt < EXPIRY_MS + } + // Remove only the fully-matched record (single-use); always prune expired ones. A request_id that + // matches but with a mismatched wallet/currency is left intact so a tampered redirect cannot burn it. + stored.filter { it != matched }.filterNotExpired(now) + } + matched?.let(pendingOfframpConverter::convert) + } + + private fun List.filterNotExpired(now: Long): List = + filter { now - it.createdAt < EXPIRY_MS } + + private companion object { + val EXPIRY_MS: Long = TimeUnit.HOURS.toMillis(1) + } } \ No newline at end of file diff --git a/app/src/main/java/com/tangem/tap/data/converter/PendingOfframpEntryConverter.kt b/app/src/main/java/com/tangem/tap/data/converter/PendingOfframpEntryConverter.kt new file mode 100644 index 0000000000..9be3bdb848 --- /dev/null +++ b/app/src/main/java/com/tangem/tap/data/converter/PendingOfframpEntryConverter.kt @@ -0,0 +1,19 @@ +package com.tangem.tap.data.converter + +import com.tangem.domain.models.wallet.UserWalletId +import com.tangem.domain.offramp.model.PendingOfframp +import com.tangem.tap.data.model.PendingOfframpEntry +import com.tangem.utils.converter.Converter + +/** + * Converts a persisted [PendingOfframpEntry] into the domain [PendingOfframp]. + */ +internal class PendingOfframpEntryConverter : Converter { + + override fun convert(value: PendingOfframpEntry): PendingOfframp = PendingOfframp( + requestId = value.requestId, + userWalletId = UserWalletId(stringValue = value.userWalletId), + currencyId = value.currencyId, + createdAt = value.createdAt, + ) +} \ No newline at end of file diff --git a/app/src/main/java/com/tangem/tap/data/model/PendingOfframpEntry.kt b/app/src/main/java/com/tangem/tap/data/model/PendingOfframpEntry.kt new file mode 100644 index 0000000000..29304bba31 --- /dev/null +++ b/app/src/main/java/com/tangem/tap/data/model/PendingOfframpEntry.kt @@ -0,0 +1,23 @@ +package com.tangem.tap.data.model + +import kotlinx.serialization.SerialName +import kotlinx.serialization.Serializable + +/** + * Persisted entry of an app-initiated sell (off-ramp) flow, stored in a dedicated kotlinx-serialized DataStore. + * + * [userWalletId] holds the [com.tangem.domain.models.wallet.UserWalletId.stringValue]. + * + * @see com.tangem.domain.offramp.model.PendingOfframp + */ +@Serializable +internal data class PendingOfframpEntry( + @SerialName("requestId") + val requestId: String, + @SerialName("userWalletId") + val userWalletId: String, + @SerialName("currencyId") + val currencyId: String, + @SerialName("createdAt") + val createdAt: Long, +) \ No newline at end of file diff --git a/app/src/main/java/com/tangem/tap/di/domain/OfframpDomainModule.kt b/app/src/main/java/com/tangem/tap/di/domain/OfframpDomainModule.kt new file mode 100644 index 0000000000..baa62b68a1 --- /dev/null +++ b/app/src/main/java/com/tangem/tap/di/domain/OfframpDomainModule.kt @@ -0,0 +1,56 @@ +package com.tangem.tap.di.domain + +import android.content.Context +import androidx.datastore.core.DataStore +import androidx.datastore.core.DataStoreFactory +import androidx.datastore.dataStoreFile +import com.tangem.datasource.utils.KotlinxDataStoreSerializer +import com.tangem.domain.offramp.GetOfframpUrlUseCase +import com.tangem.domain.offramp.repository.OfframpRepository +import com.tangem.tap.data.DefaultOfframpRepository +import com.tangem.tap.data.model.PendingOfframpEntry +import com.tangem.tap.network.exchangeServices.SellService +import com.tangem.utils.coroutines.AppCoroutineScope +import com.tangem.utils.coroutines.CoroutineDispatcherProvider +import dagger.Module +import dagger.Provides +import dagger.hilt.InstallIn +import dagger.hilt.android.qualifiers.ApplicationContext +import dagger.hilt.components.SingletonComponent +import javax.inject.Singleton +import kotlinx.serialization.builtins.ListSerializer + +@Module +@InstallIn(SingletonComponent::class) +internal object OfframpDomainModule { + + @Provides + @Singleton + fun providePendingOfframpStore( + @ApplicationContext context: Context, + appScope: AppCoroutineScope, + ): DataStore> = DataStoreFactory.create( + serializer = KotlinxDataStoreSerializer( + defaultValue = emptyList(), + serializer = ListSerializer(PendingOfframpEntry.serializer()), + ), + produceFile = { context.dataStoreFile(fileName = "pending_offramps") }, + scope = appScope, + ) + + @Provides + @Singleton + fun provideOfframpRepository( + sellService: SellService, + pendingOfframpStore: DataStore>, + dispatchers: CoroutineDispatcherProvider, + ): OfframpRepository { + return DefaultOfframpRepository(sellService, pendingOfframpStore, dispatchers) + } + + @Provides + @Singleton + fun provideGetOfframpUrlUseCase(offrampRepository: OfframpRepository): GetOfframpUrlUseCase { + return GetOfframpUrlUseCase(offrampRepository) + } +} \ No newline at end of file diff --git a/app/src/main/java/com/tangem/tap/di/domain/OnrampDomainModule.kt b/app/src/main/java/com/tangem/tap/di/domain/OnrampDomainModule.kt index c372bdbd2b..4bc8f028bb 100644 --- a/app/src/main/java/com/tangem/tap/di/domain/OnrampDomainModule.kt +++ b/app/src/main/java/com/tangem/tap/di/domain/OnrampDomainModule.kt @@ -1,12 +1,8 @@ package com.tangem.tap.di.domain -import com.tangem.domain.offramp.GetOfframpUrlUseCase -import com.tangem.domain.offramp.repository.OfframpRepository import com.tangem.domain.onramp.* import com.tangem.domain.onramp.repositories.* import com.tangem.domain.settings.repositories.SettingsRepository -import com.tangem.tap.data.DefaultOfframpRepository -import com.tangem.tap.network.exchangeServices.SellService import dagger.Module import dagger.Provides import dagger.hilt.InstallIn @@ -270,16 +266,4 @@ internal object OnrampDomainModule { settingsRepository = settingsRepository, ) } - - @Provides - @Singleton - fun provideOfframpRepository(sellService: SellService): OfframpRepository { - return DefaultOfframpRepository(sellService) - } - - @Provides - @Singleton - fun provideGetOfframpUrlUseCase(offrampRepository: OfframpRepository): GetOfframpUrlUseCase { - return GetOfframpUrlUseCase(offrampRepository) - } } \ No newline at end of file diff --git a/app/src/main/java/com/tangem/tap/network/exchangeServices/SellService.kt b/app/src/main/java/com/tangem/tap/network/exchangeServices/SellService.kt index 4075e7dd57..97d7caa711 100644 --- a/app/src/main/java/com/tangem/tap/network/exchangeServices/SellService.kt +++ b/app/src/main/java/com/tangem/tap/network/exchangeServices/SellService.kt @@ -20,5 +20,6 @@ interface SellService { fiatCurrencyName: String, walletAddress: String, isDarkTheme: Boolean, + requestId: String, ): String? } \ No newline at end of file diff --git a/app/src/main/java/com/tangem/tap/network/exchangeServices/moonpay/MoonPayService.kt b/app/src/main/java/com/tangem/tap/network/exchangeServices/moonpay/MoonPayService.kt index 8807a899e6..10f1730935 100644 --- a/app/src/main/java/com/tangem/tap/network/exchangeServices/moonpay/MoonPayService.kt +++ b/app/src/main/java/com/tangem/tap/network/exchangeServices/moonpay/MoonPayService.kt @@ -138,6 +138,7 @@ class MoonPayService( fiatCurrencyName: String, walletAddress: String, isDarkTheme: Boolean, + requestId: String, ): String? { val blockchain = cryptoCurrency.network.toBlockchain() if (blockchain.isTestnet()) return blockchain.getTestnetTopUpUrl() @@ -165,7 +166,12 @@ class MoonPayService( .appendQueryParameter("apiKey", apiKey) .appendQueryParameter("baseCurrencyCode", moonpayCurrency.currencyCode.uppercase()) .appendQueryParameter("refundWalletAddress", walletAddress) - .appendQueryParameter("redirectURL", "tangem://redirect_sell?currency_id=${cryptoCurrency.id.value}") + // request_id authenticates the returning redirect_sell deeplink. It must be added to + // redirectURL BEFORE createSignature below so it is covered by the MoonPay URL signature. + .appendQueryParameter( + "redirectURL", + "tangem://redirect_sell?currency_id=${cryptoCurrency.id.value}&request_id=$requestId", + ) if (isDarkTheme) uri.appendQueryParameter("theme", "dark") diff --git a/app/src/test/kotlin/com/tangem/tap/data/DefaultOfframpRepositoryTest.kt b/app/src/test/kotlin/com/tangem/tap/data/DefaultOfframpRepositoryTest.kt index 16ba7e686f..7c7f6f4c98 100644 --- a/app/src/test/kotlin/com/tangem/tap/data/DefaultOfframpRepositoryTest.kt +++ b/app/src/test/kotlin/com/tangem/tap/data/DefaultOfframpRepositoryTest.kt @@ -1,28 +1,54 @@ package com.tangem.tap.data +import androidx.datastore.core.DataStore import com.google.common.truth.Truth.assertThat import com.tangem.domain.models.currency.CryptoCurrency +import com.tangem.domain.models.wallet.UserWalletId import com.tangem.tap.common.apptheme.MutableAppThemeModeHolder +import com.tangem.tap.data.model.PendingOfframpEntry import com.tangem.tap.network.exchangeServices.SellService +import com.tangem.utils.coroutines.TestingCoroutineDispatcherProvider import io.mockk.* +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.test.runTest import org.junit.jupiter.api.AfterEach import org.junit.jupiter.api.BeforeEach import org.junit.jupiter.api.Test import org.junit.jupiter.api.TestInstance +import java.util.concurrent.TimeUnit @TestInstance(TestInstance.Lifecycle.PER_CLASS) internal class DefaultOfframpRepositoryTest { private val sellService: SellService = mockk() - private val repository = DefaultOfframpRepository(sellService) + private val pendingStoreState = MutableStateFlow>(emptyList()) + private val pendingOfframpStore = object : DataStore> { + override val data = pendingStoreState + override suspend fun updateData( + transform: suspend (t: List) -> List, + ): List { + val updated = transform(pendingStoreState.value) + pendingStoreState.value = updated + return updated + } + } + private val repository = DefaultOfframpRepository( + sellService = sellService, + pendingOfframpStore = pendingOfframpStore, + dispatchers = TestingCoroutineDispatcherProvider(), + ) private val cryptoCurrency: CryptoCurrency = mockk() private val fiatCurrencyCode = "USD" private val walletAddress = "0x1234567890abcdef" + private val requestId = "request-id-001" + private val userWalletId = UserWalletId("0011223344556677") + private val currencyId = "bitcoin" @BeforeEach fun setUp() { mockkObject(MutableAppThemeModeHolder) + pendingStoreState.value = emptyList() } @AfterEach @@ -42,6 +68,7 @@ internal class DefaultOfframpRepositoryTest { fiatCurrencyName = fiatCurrencyCode, walletAddress = walletAddress, isDarkTheme = false, + requestId = requestId, ) } returns expectedUrl @@ -50,17 +77,18 @@ internal class DefaultOfframpRepositoryTest { cryptoCurrency = cryptoCurrency, fiatCurrencyCode = fiatCurrencyCode, walletAddress = walletAddress, + requestId = requestId, ) // Assert assertThat(result).isEqualTo(expectedUrl) - verify(exactly = 1) { sellService.getUrl( cryptoCurrency = cryptoCurrency, fiatCurrencyName = fiatCurrencyCode, walletAddress = walletAddress, isDarkTheme = false, + requestId = requestId, ) } } @@ -76,6 +104,7 @@ internal class DefaultOfframpRepositoryTest { fiatCurrencyName = fiatCurrencyCode, walletAddress = walletAddress, isDarkTheme = true, + requestId = requestId, ) } returns expectedUrl @@ -84,17 +113,18 @@ internal class DefaultOfframpRepositoryTest { cryptoCurrency = cryptoCurrency, fiatCurrencyCode = fiatCurrencyCode, walletAddress = walletAddress, + requestId = requestId, ) // Assert assertThat(result).isEqualTo(expectedUrl) - verify(exactly = 1) { sellService.getUrl( cryptoCurrency = cryptoCurrency, fiatCurrencyName = fiatCurrencyCode, walletAddress = walletAddress, isDarkTheme = true, + requestId = requestId, ) } } @@ -109,6 +139,7 @@ internal class DefaultOfframpRepositoryTest { fiatCurrencyName = fiatCurrencyCode, walletAddress = walletAddress, isDarkTheme = false, + requestId = requestId, ) } returns null @@ -117,18 +148,92 @@ internal class DefaultOfframpRepositoryTest { cryptoCurrency = cryptoCurrency, fiatCurrencyCode = fiatCurrencyCode, walletAddress = walletAddress, + requestId = requestId, ) // Assert assertThat(result).isNull() - - verify(exactly = 1) { - sellService.getUrl( - cryptoCurrency = cryptoCurrency, - fiatCurrencyName = fiatCurrencyCode, - walletAddress = walletAddress, - isDarkTheme = false, - ) - } } -} + + @Test + fun `GIVEN registered pending offramp WHEN consume with matching wallet and currency THEN returns record`() = + runTest { + // Arrange + val storedRequestId = repository.registerPendingOfframp(userWalletId, currencyId) + + // Act + val pending = repository.consumePendingOfframp(storedRequestId, userWalletId, currencyId) + + // Assert + assertThat(pending).isNotNull() + assertThat(pending?.requestId).isEqualTo(storedRequestId) + assertThat(pending?.userWalletId).isEqualTo(userWalletId) + assertThat(pending?.currencyId).isEqualTo(currencyId) + } + + @Test + fun `GIVEN unknown request id WHEN consume THEN returns null`() = runTest { + repository.registerPendingOfframp(userWalletId, currencyId) + + assertThat(repository.consumePendingOfframp("unknown", userWalletId, currencyId)).isNull() + } + + @Test + fun `GIVEN already consumed pending offramp WHEN consume again THEN returns null`() = runTest { + // Arrange + val storedRequestId = repository.registerPendingOfframp(userWalletId, currencyId) + + // Act + val first = repository.consumePendingOfframp(storedRequestId, userWalletId, currencyId) + val second = repository.consumePendingOfframp(storedRequestId, userWalletId, currencyId) + + // Assert + assertThat(first).isNotNull() + assertThat(second).isNull() + } + + @Test + fun `GIVEN mismatched currency WHEN consume THEN returns null and does NOT burn the pending sell`() = runTest { + // Arrange + val storedRequestId = repository.registerPendingOfframp(userWalletId, currencyId) + + // Act — a tampered redirect with the right request_id but a wrong currency must not consume the token + val mismatched = repository.consumePendingOfframp(storedRequestId, userWalletId, currencyId = "ethereum") + // ...so the legitimate redirect can still succeed afterwards + val legitimate = repository.consumePendingOfframp(storedRequestId, userWalletId, currencyId) + + // Assert + assertThat(mismatched).isNull() + assertThat(legitimate).isNotNull() + assertThat(legitimate?.currencyId).isEqualTo(currencyId) + } + + @Test + fun `GIVEN mismatched wallet WHEN consume THEN returns null`() = runTest { + val storedRequestId = repository.registerPendingOfframp(userWalletId, currencyId) + + val result = repository.consumePendingOfframp(storedRequestId, UserWalletId("ffeeddccbbaa9988"), currencyId) + + assertThat(result).isNull() + } + + @Test + fun `GIVEN expired pending offramp WHEN consume THEN returns null`() = runTest { + // Arrange — seed a record created 2 hours ago (past the 1h expiry) + val expiredId = "expired-id" + pendingStoreState.value = listOf( + PendingOfframpEntry( + requestId = expiredId, + userWalletId = userWalletId.stringValue, + currencyId = currencyId, + createdAt = System.currentTimeMillis() - TimeUnit.HOURS.toMillis(2), + ), + ) + + // Act + val pending = repository.consumePendingOfframp(expiredId, userWalletId, currencyId) + + // Assert + assertThat(pending).isNull() + } +} \ No newline at end of file diff --git a/app/src/test/kotlin/com/tangem/tap/data/converter/PendingOfframpEntryConverterTest.kt b/app/src/test/kotlin/com/tangem/tap/data/converter/PendingOfframpEntryConverterTest.kt new file mode 100644 index 0000000000..7e90724055 --- /dev/null +++ b/app/src/test/kotlin/com/tangem/tap/data/converter/PendingOfframpEntryConverterTest.kt @@ -0,0 +1,55 @@ +package com.tangem.tap.data.converter + +import com.google.common.truth.Truth.assertThat +import com.tangem.domain.models.wallet.UserWalletId +import com.tangem.domain.offramp.model.PendingOfframp +import com.tangem.tap.data.model.PendingOfframpEntry +import org.junit.jupiter.api.Test +import org.junit.jupiter.api.TestInstance + +@TestInstance(TestInstance.Lifecycle.PER_CLASS) +internal class PendingOfframpEntryConverterTest { + + private val converter = PendingOfframpEntryConverter() + + @Test + fun `GIVEN entry WHEN convert THEN maps all fields and wraps wallet id`() { + // Arrange + val entry = PendingOfframpEntry( + requestId = "request-id-001", + userWalletId = "0011223344556677", + currencyId = "bitcoin", + createdAt = 1_700_000_000_000L, + ) + + // Act + val result = converter.convert(entry) + + // Assert + val expected = PendingOfframp( + requestId = "request-id-001", + userWalletId = UserWalletId(stringValue = "0011223344556677"), + currencyId = "bitcoin", + createdAt = 1_700_000_000_000L, + ) + assertThat(result).isEqualTo(expected) + } + + @Test + fun `GIVEN entries WHEN convertList THEN converts each preserving order`() { + // Arrange + val entries = listOf( + PendingOfframpEntry("id-1", "0011", "bitcoin", 1L), + PendingOfframpEntry("id-2", "0022", "ethereum", 2L), + ) + + // Act + val result = converter.convertList(entries) + + // Assert + assertThat(result).containsExactly( + PendingOfframp("id-1", UserWalletId("0011"), "bitcoin", 1L), + PendingOfframp("id-2", UserWalletId("0022"), "ethereum", 2L), + ).inOrder() + } +} \ No newline at end of file diff --git a/common/ui-markets/src/main/kotlin/com/tangem/common/ui/markets/action/TokenActionsHandler.kt b/common/ui-markets/src/main/kotlin/com/tangem/common/ui/markets/action/TokenActionsHandler.kt index 4685c359d0..0612566c9a 100644 --- a/common/ui-markets/src/main/kotlin/com/tangem/common/ui/markets/action/TokenActionsHandler.kt +++ b/common/ui-markets/src/main/kotlin/com/tangem/common/ui/markets/action/TokenActionsHandler.kt @@ -23,6 +23,8 @@ import com.tangem.utils.Provider import dagger.assisted.Assisted import dagger.assisted.AssistedFactory import dagger.assisted.AssistedInject +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.launch import kotlinx.collections.immutable.toImmutableList @Suppress("LongParameterList") @@ -35,6 +37,7 @@ class TokenActionsHandler @AssistedInject constructor( private val analyticsEventHandler: AnalyticsEventHandler, @Assisted private val currentAppCurrency: Provider, @Assisted private val onHandleQuickAction: (action: HandledQuickAction, shouldDismiss: Boolean) -> Unit, + @Assisted private val coroutineScope: CoroutineScope, private val isDemoCardUseCase: IsDemoCardUseCase, private val messageSender: UiMessageSender, ) { @@ -118,12 +121,15 @@ class TokenActionsHandler @AssistedInject constructor( } private fun onSellClick(cryptoCurrencyData: CryptoCurrencyData) { - getOfframpUrlUseCase( - cryptoCurrencyStatus = cryptoCurrencyData.status, - appCurrencyCode = currentAppCurrency().code, - ).onRight { url -> - urlOpener.openUrl(url) - analyticsEventHandler.send(OfframpAnalyticsEvent.ScreenOpened) + coroutineScope.launch { + getOfframpUrlUseCase( + userWalletId = cryptoCurrencyData.userWallet.walletId, + cryptoCurrencyStatus = cryptoCurrencyData.status, + appCurrencyCode = currentAppCurrency().code, + ).onRight { url -> + urlOpener.openUrl(url) + analyticsEventHandler.send(OfframpAnalyticsEvent.ScreenOpened) + } } } @@ -177,6 +183,7 @@ class TokenActionsHandler @AssistedInject constructor( fun create( currentAppCurrency: Provider, onHandleQuickAction: (HandledQuickAction, shouldDismiss: Boolean) -> Unit, + coroutineScope: CoroutineScope, ): TokenActionsHandler } diff --git a/domain/offramp/src/main/java/com/tangem/domain/offramp/GetOfframpUrlUseCase.kt b/domain/offramp/src/main/java/com/tangem/domain/offramp/GetOfframpUrlUseCase.kt index 75b764ac2d..4a57b0c029 100644 --- a/domain/offramp/src/main/java/com/tangem/domain/offramp/GetOfframpUrlUseCase.kt +++ b/domain/offramp/src/main/java/com/tangem/domain/offramp/GetOfframpUrlUseCase.kt @@ -4,10 +4,15 @@ import arrow.core.Either import arrow.core.raise.either import arrow.core.raise.ensure import com.tangem.domain.models.currency.CryptoCurrencyStatus +import com.tangem.domain.models.wallet.UserWalletId import com.tangem.domain.offramp.repository.OfframpRepository +import com.tangem.utils.logging.TangemLogger /** - * Use case for getting offramp (sell crypto) URL + * Use case for getting offramp (sell crypto) URL. + * + * Registers a single-use `request_id` in [OfframpRepository] and embeds it into the provider redirect URL so the + * returning `redirect_sell` deeplink can be validated as a real, user-initiated sell. * * @property offrampRepository repository for offramp operations */ @@ -15,20 +20,30 @@ class GetOfframpUrlUseCase( private val offrampRepository: OfframpRepository, ) { - operator fun invoke(cryptoCurrencyStatus: CryptoCurrencyStatus, appCurrencyCode: String): Either = - either { - val walletAddress = cryptoCurrencyStatus.value.networkAddress?.defaultAddress?.value - ensure(walletAddress != null) { Error.WalletAddressNotFound } + suspend operator fun invoke( + userWalletId: UserWalletId, + cryptoCurrencyStatus: CryptoCurrencyStatus, + appCurrencyCode: String, + ): Either = either { + val walletAddress = cryptoCurrencyStatus.value.networkAddress?.defaultAddress?.value + ensure(walletAddress != null) { Error.WalletAddressNotFound } - val url = offrampRepository.getOfframpUrl( - cryptoCurrency = cryptoCurrencyStatus.currency, - fiatCurrencyCode = appCurrencyCode, - walletAddress = walletAddress, - ) - ensure(url != null) { Error.UrlNotAvailable } + val requestId = offrampRepository.registerPendingOfframp( + userWalletId = userWalletId, + currencyId = cryptoCurrencyStatus.currency.id.value, + ) - url - } + val url = offrampRepository.getOfframpUrl( + cryptoCurrency = cryptoCurrencyStatus.currency, + fiatCurrencyCode = appCurrencyCode, + walletAddress = walletAddress, + requestId = requestId, + ) + ensure(url != null) { Error.UrlNotAvailable } + + url + } + .onLeft { TangemLogger.e("Error getting offramp URL: $it") } /** Offramp use case errors */ sealed class Error { diff --git a/domain/offramp/src/main/java/com/tangem/domain/offramp/model/PendingOfframp.kt b/domain/offramp/src/main/java/com/tangem/domain/offramp/model/PendingOfframp.kt new file mode 100644 index 0000000000..c3fc9dee2a --- /dev/null +++ b/domain/offramp/src/main/java/com/tangem/domain/offramp/model/PendingOfframp.kt @@ -0,0 +1,22 @@ +package com.tangem.domain.offramp.model + +import com.tangem.domain.models.wallet.UserWalletId + +/** + * A locally-recorded marker that the app itself initiated a sell (off-ramp) flow. + * + + * redirects back via the `redirect_sell` deeplink, the returned `request_id` is matched against a stored + * [PendingOfframp] to prove the redirect corresponds to a real, user-initiated sell. + * + * @property requestId self-issued single-use nonce embedded in the provider redirect URL + * @property userWalletId wallet that initiated the sell + * @property currencyId [com.tangem.domain.models.currency.CryptoCurrency.ID.value] being sold + + */ +data class PendingOfframp( + val requestId: String, + val userWalletId: UserWalletId, + val currencyId: String, + val createdAt: Long, +) \ No newline at end of file diff --git a/domain/offramp/src/main/java/com/tangem/domain/offramp/repository/OfframpRepository.kt b/domain/offramp/src/main/java/com/tangem/domain/offramp/repository/OfframpRepository.kt index 0fdfca218b..b93b8e77c0 100644 --- a/domain/offramp/src/main/java/com/tangem/domain/offramp/repository/OfframpRepository.kt +++ b/domain/offramp/src/main/java/com/tangem/domain/offramp/repository/OfframpRepository.kt @@ -1,6 +1,8 @@ package com.tangem.domain.offramp.repository import com.tangem.domain.models.currency.CryptoCurrency +import com.tangem.domain.models.wallet.UserWalletId +import com.tangem.domain.offramp.model.PendingOfframp /** * Repository for offramp (sell crypto) operations @@ -13,7 +15,31 @@ interface OfframpRepository { * @param cryptoCurrency crypto currency to sell * @param fiatCurrencyCode fiat currency code (e.g., "USD", "EUR") * @param walletAddress wallet address for the refund + * @param requestId single-use nonce embedded into the provider redirect URL to authenticate the + * returning `redirect_sell` deeplink * @return URL for offramp service or null if not available */ - fun getOfframpUrl(cryptoCurrency: CryptoCurrency, fiatCurrencyCode: String, walletAddress: String): String? + fun getOfframpUrl( + cryptoCurrency: CryptoCurrency, + fiatCurrencyCode: String, + walletAddress: String, + requestId: String, + ): String? + + /** + * Registers a new app-initiated sell for [userWalletId] / [currencyId], prunes expired records, and returns a + * fresh single-use `request_id` to embed in the provider redirect URL. + */ + suspend fun registerPendingOfframp(userWalletId: UserWalletId, currencyId: String): String + + /** + * Returns and removes (single-use) the pending sell matching [requestId] only when it is not expired and was + * registered for the same [userWalletId] and [currencyId]. Returns `null` otherwise, leaving a non-matching + * record untouched so a tampered redirect cannot burn a legitimate pending sell. + */ + suspend fun consumePendingOfframp( + requestId: String, + userWalletId: UserWalletId, + currencyId: String, + ): PendingOfframp? } \ No newline at end of file diff --git a/domain/offramp/src/test/kotlin/com/tangem/domain/offramp/GetOfframpUrlUseCaseTest.kt b/domain/offramp/src/test/kotlin/com/tangem/domain/offramp/GetOfframpUrlUseCaseTest.kt index 2ae52dafbe..484a0c990d 100644 --- a/domain/offramp/src/test/kotlin/com/tangem/domain/offramp/GetOfframpUrlUseCaseTest.kt +++ b/domain/offramp/src/test/kotlin/com/tangem/domain/offramp/GetOfframpUrlUseCaseTest.kt @@ -4,11 +4,14 @@ import com.google.common.truth.Truth.assertThat import com.tangem.domain.models.currency.CryptoCurrency import com.tangem.domain.models.currency.CryptoCurrencyStatus import com.tangem.domain.models.network.NetworkAddress +import com.tangem.domain.models.wallet.UserWalletId import com.tangem.domain.offramp.repository.OfframpRepository 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.runTest import org.junit.jupiter.api.BeforeEach import org.junit.jupiter.api.Test import org.junit.jupiter.api.TestInstance @@ -19,7 +22,12 @@ class GetOfframpUrlUseCaseTest { private val offrampRepository: OfframpRepository = mockk() private val useCase = GetOfframpUrlUseCase(offrampRepository) - private val cryptoCurrency: CryptoCurrency = mockk() + private val userWalletId = UserWalletId("011") + private val currencyId = "bitcoin" + private val requestId = "request-id-001" + private val cryptoCurrency: CryptoCurrency = mockk { + every { id } returns mockk { every { value } returns currencyId } + } private val appCurrencyCode = "USD" private val walletAddress = "0x1234567890abcdef" private val expectedUrl = "https://moonpay.com/sell?address=$walletAddress" @@ -27,77 +35,82 @@ class GetOfframpUrlUseCaseTest { @BeforeEach fun resetMocks() { clearMocks(offrampRepository) + coEvery { offrampRepository.registerPendingOfframp(any(), any()) } returns requestId } @Test - fun `invoke should return url when wallet address and url are available`() { + fun `invoke should register request_id and return url when wallet address and url are available`() = runTest { // Arrange val cryptoCurrencyStatus = createCryptoCurrencyStatus(walletAddress = walletAddress) - every { + coEvery { offrampRepository.getOfframpUrl( cryptoCurrency = cryptoCurrency, fiatCurrencyCode = appCurrencyCode, walletAddress = walletAddress, + requestId = requestId, ) } returns expectedUrl // Act - val result = useCase(cryptoCurrencyStatus, appCurrencyCode) + val result = useCase(userWalletId, cryptoCurrencyStatus, appCurrencyCode) // Assert assertThat(result.isRight()).isTrue() assertThat(result.getOrNull()).isEqualTo(expectedUrl) - verify(exactly = 1) { + coVerify(exactly = 1) { offrampRepository.registerPendingOfframp(userWalletId, currencyId) } + coVerify(exactly = 1) { offrampRepository.getOfframpUrl( cryptoCurrency = cryptoCurrency, fiatCurrencyCode = appCurrencyCode, walletAddress = walletAddress, + requestId = requestId, ) } } @Test - fun `invoke should return WalletAddressNotFound error when network address is null`() { + fun `invoke should return WalletAddressNotFound error when network address is null`() = runTest { // Arrange val cryptoCurrencyStatus = createCryptoCurrencyStatus(networkAddress = null) // Act - val result = useCase(cryptoCurrencyStatus, appCurrencyCode) + val result = useCase(userWalletId, cryptoCurrencyStatus, appCurrencyCode) // Assert assertThat(result.isLeft()).isTrue() assertThat(result.leftOrNull()).isEqualTo(GetOfframpUrlUseCase.Error.WalletAddressNotFound) - verify(exactly = 0) { - offrampRepository.getOfframpUrl(any(), any(), any()) - } + coVerify(exactly = 0) { offrampRepository.registerPendingOfframp(any(), any()) } + coVerify(exactly = 0) { offrampRepository.getOfframpUrl(any(), any(), any(), any()) } } @Test - fun `invoke should return UrlNotAvailable error when repository returns null`() { + fun `invoke should return UrlNotAvailable error when repository returns null`() = runTest { // Arrange val cryptoCurrencyStatus = createCryptoCurrencyStatus(walletAddress = walletAddress) - every { + coEvery { offrampRepository.getOfframpUrl( cryptoCurrency = cryptoCurrency, fiatCurrencyCode = appCurrencyCode, walletAddress = walletAddress, + requestId = requestId, ) } returns null // Act - val result = useCase(cryptoCurrencyStatus, appCurrencyCode) + val result = useCase(userWalletId, cryptoCurrencyStatus, appCurrencyCode) // Assert assertThat(result.isLeft()).isTrue() assertThat(result.leftOrNull()).isEqualTo(GetOfframpUrlUseCase.Error.UrlNotAvailable) - verify(exactly = 1) { + coVerify(exactly = 1) { offrampRepository.getOfframpUrl( cryptoCurrency = cryptoCurrency, fiatCurrencyCode = appCurrencyCode, walletAddress = walletAddress, + requestId = requestId, ) } } @@ -123,5 +136,4 @@ class GetOfframpUrlUseCaseTest { every { value } returns statusValue } } -} - +} \ No newline at end of file diff --git a/features/common-features/impl/src/main/java/com/tangem/features/commonfeatures/impl/tokenactions/model/TokenActionsModel.kt b/features/common-features/impl/src/main/java/com/tangem/features/commonfeatures/impl/tokenactions/model/TokenActionsModel.kt index 9dfca10ac8..68b9456cbc 100644 --- a/features/common-features/impl/src/main/java/com/tangem/features/commonfeatures/impl/tokenactions/model/TokenActionsModel.kt +++ b/features/common-features/impl/src/main/java/com/tangem/features/commonfeatures/impl/tokenactions/model/TokenActionsModel.kt @@ -48,6 +48,7 @@ internal class TokenActionsModel @Inject constructor( onHandleQuickAction = { handledAction, shouldDismiss -> handledQuickAction(handledAction, shouldDismiss) }, + coroutineScope = modelScope, ) val bottomSheetNavigation: SlotNavigation = SlotNavigation() diff --git a/features/feed/impl/src/main/kotlin/com/tangem/features/feed/components/market/details/portfolio/impl/model/MarketsPortfolioModel.kt b/features/feed/impl/src/main/kotlin/com/tangem/features/feed/components/market/details/portfolio/impl/model/MarketsPortfolioModel.kt index 444eff2564..bee93e82b3 100644 --- a/features/feed/impl/src/main/kotlin/com/tangem/features/feed/components/market/details/portfolio/impl/model/MarketsPortfolioModel.kt +++ b/features/feed/impl/src/main/kotlin/com/tangem/features/feed/components/market/details/portfolio/impl/model/MarketsPortfolioModel.kt @@ -151,6 +151,7 @@ internal class MarketsPortfolioModel @Inject constructor( ) configureReceiveAddresses(handledAction) }, + coroutineScope = modelScope, ) } diff --git a/features/onramp/impl/src/main/kotlin/com/tangem/features/onramp/selecttoken/model/OnrampOperationModel.kt b/features/onramp/impl/src/main/kotlin/com/tangem/features/onramp/selecttoken/model/OnrampOperationModel.kt index a9ac694ab9..49b16a3cf1 100644 --- a/features/onramp/impl/src/main/kotlin/com/tangem/features/onramp/selecttoken/model/OnrampOperationModel.kt +++ b/features/onramp/impl/src/main/kotlin/com/tangem/features/onramp/selecttoken/model/OnrampOperationModel.kt @@ -121,6 +121,7 @@ internal class OnrampOperationModel @Inject constructor( .getOrElse { AppCurrency.Default }.code getOfframpUrlUseCase( + userWalletId = selectedUserWallet.walletId, cryptoCurrencyStatus = status, appCurrencyCode = appCurrencyCode, ).onRight { url -> diff --git a/features/send/impl/build.gradle.kts b/features/send/impl/build.gradle.kts index 8fad471329..d5b2231051 100644 --- a/features/send/impl/build.gradle.kts +++ b/features/send/impl/build.gradle.kts @@ -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) diff --git a/features/send/impl/src/main/java/com/tangem/features/send/deeplink/DefaultSellRedirectDeepLinkHandler.kt b/features/send/impl/src/main/java/com/tangem/features/send/deeplink/DefaultSellRedirectDeepLinkHandler.kt index 34dae6b817..9d23ebb812 100644 --- a/features/send/impl/src/main/java/com/tangem/features/send/deeplink/DefaultSellRedirectDeepLinkHandler.kt +++ b/features/send/impl/src/main/java/com/tangem/features/send/deeplink/DefaultSellRedirectDeepLinkHandler.kt @@ -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, 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" } } \ No newline at end of file diff --git a/features/send/impl/src/test/java/com/tangem/features/send/deeplink/DefaultSellRedirectDeepLinkHandlerTest.kt b/features/send/impl/src/test/java/com/tangem/features/send/deeplink/DefaultSellRedirectDeepLinkHandlerTest.kt new file mode 100644 index 0000000000..fd7a06c9a4 --- /dev/null +++ b/features/send/impl/src/test/java/com/tangem/features/send/deeplink/DefaultSellRedirectDeepLinkHandlerTest.kt @@ -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()) } 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()) } + 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()) } + verify(exactly = 0) { appRouter.push(any()) } + } + + private fun TestScope.createHandler(queryParams: Map) = 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" + } +} \ No newline at end of file diff --git a/features/tokendetails/impl/src/main/kotlin/com/tangem/feature/tokendetails/presentation/tokendetails/model/TokenDetailsModel.kt b/features/tokendetails/impl/src/main/kotlin/com/tangem/feature/tokendetails/presentation/tokendetails/model/TokenDetailsModel.kt index 5aa1becb9c..e5734666af 100644 --- a/features/tokendetails/impl/src/main/kotlin/com/tangem/feature/tokendetails/presentation/tokendetails/model/TokenDetailsModel.kt +++ b/features/tokendetails/impl/src/main/kotlin/com/tangem/feature/tokendetails/presentation/tokendetails/model/TokenDetailsModel.kt @@ -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) + } } } } diff --git a/features/wallet/impl/src/main/java/com/tangem/feature/wallet/child/wallet/model/intents/WalletCurrencyActionsClickIntents.kt b/features/wallet/impl/src/main/java/com/tangem/feature/wallet/child/wallet/model/intents/WalletCurrencyActionsClickIntents.kt index 34eb71249c..dfa746b3dd 100644 --- a/features/wallet/impl/src/main/java/com/tangem/feature/wallet/child/wallet/model/intents/WalletCurrencyActionsClickIntents.kt +++ b/features/wallet/impl/src/main/java/com/tangem/feature/wallet/child/wallet/model/intents/WalletCurrencyActionsClickIntents.kt @@ -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) } }