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

@ -1,29 +1,89 @@
package com.tangem.tap.data package com.tangem.tap.data
import androidx.datastore.core.DataStore
import com.tangem.domain.models.currency.CryptoCurrency 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.domain.offramp.repository.OfframpRepository
import com.tangem.tap.common.apptheme.MutableAppThemeModeHolder 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.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 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( internal class DefaultOfframpRepository(
private val sellService: SellService, private val sellService: SellService,
private val pendingOfframpStore: DataStore<List<PendingOfframpEntry>>,
private val dispatchers: CoroutineDispatcherProvider,
) : OfframpRepository { ) : OfframpRepository {
private val pendingOfframpConverter = PendingOfframpEntryConverter()
override fun getOfframpUrl( override fun getOfframpUrl(
cryptoCurrency: CryptoCurrency, cryptoCurrency: CryptoCurrency,
fiatCurrencyCode: String, fiatCurrencyCode: String,
walletAddress: String, walletAddress: String,
requestId: String,
): String? { ): String? {
return sellService.getUrl( return sellService.getUrl(
cryptoCurrency = cryptoCurrency, cryptoCurrency = cryptoCurrency,
fiatCurrencyName = fiatCurrencyCode, fiatCurrencyName = fiatCurrencyCode,
walletAddress = walletAddress, walletAddress = walletAddress,
isDarkTheme = MutableAppThemeModeHolder.isDarkThemeActive, 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<PendingOfframpEntry>.filterNotExpired(now: Long): List<PendingOfframpEntry> =
filter { now - it.createdAt < EXPIRY_MS }
private companion object {
val EXPIRY_MS: Long = TimeUnit.HOURS.toMillis(1)
}
} }

View file

@ -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<PendingOfframpEntry, PendingOfframp> {
override fun convert(value: PendingOfframpEntry): PendingOfframp = PendingOfframp(
requestId = value.requestId,
userWalletId = UserWalletId(stringValue = value.userWalletId),
currencyId = value.currencyId,
createdAt = value.createdAt,
)
}

View file

@ -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,
)

View file

@ -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<List<PendingOfframpEntry>> = 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<List<PendingOfframpEntry>>,
dispatchers: CoroutineDispatcherProvider,
): OfframpRepository {
return DefaultOfframpRepository(sellService, pendingOfframpStore, dispatchers)
}
@Provides
@Singleton
fun provideGetOfframpUrlUseCase(offrampRepository: OfframpRepository): GetOfframpUrlUseCase {
return GetOfframpUrlUseCase(offrampRepository)
}
}

View file

@ -1,12 +1,8 @@
package com.tangem.tap.di.domain 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.*
import com.tangem.domain.onramp.repositories.* import com.tangem.domain.onramp.repositories.*
import com.tangem.domain.settings.repositories.SettingsRepository 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.Module
import dagger.Provides import dagger.Provides
import dagger.hilt.InstallIn import dagger.hilt.InstallIn
@ -270,16 +266,4 @@ internal object OnrampDomainModule {
settingsRepository = settingsRepository, settingsRepository = settingsRepository,
) )
} }
@Provides
@Singleton
fun provideOfframpRepository(sellService: SellService): OfframpRepository {
return DefaultOfframpRepository(sellService)
}
@Provides
@Singleton
fun provideGetOfframpUrlUseCase(offrampRepository: OfframpRepository): GetOfframpUrlUseCase {
return GetOfframpUrlUseCase(offrampRepository)
}
} }

View file

@ -20,5 +20,6 @@ interface SellService {
fiatCurrencyName: String, fiatCurrencyName: String,
walletAddress: String, walletAddress: String,
isDarkTheme: Boolean, isDarkTheme: Boolean,
requestId: String,
): String? ): String?
} }

View file

@ -138,6 +138,7 @@ class MoonPayService(
fiatCurrencyName: String, fiatCurrencyName: String,
walletAddress: String, walletAddress: String,
isDarkTheme: Boolean, isDarkTheme: Boolean,
requestId: String,
): String? { ): String? {
val blockchain = cryptoCurrency.network.toBlockchain() val blockchain = cryptoCurrency.network.toBlockchain()
if (blockchain.isTestnet()) return blockchain.getTestnetTopUpUrl() if (blockchain.isTestnet()) return blockchain.getTestnetTopUpUrl()
@ -165,7 +166,12 @@ class MoonPayService(
.appendQueryParameter("apiKey", apiKey) .appendQueryParameter("apiKey", apiKey)
.appendQueryParameter("baseCurrencyCode", moonpayCurrency.currencyCode.uppercase()) .appendQueryParameter("baseCurrencyCode", moonpayCurrency.currencyCode.uppercase())
.appendQueryParameter("refundWalletAddress", walletAddress) .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") if (isDarkTheme) uri.appendQueryParameter("theme", "dark")

View file

@ -1,28 +1,54 @@
package com.tangem.tap.data package com.tangem.tap.data
import androidx.datastore.core.DataStore
import com.google.common.truth.Truth.assertThat import com.google.common.truth.Truth.assertThat
import com.tangem.domain.models.currency.CryptoCurrency 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.common.apptheme.MutableAppThemeModeHolder
import com.tangem.tap.data.model.PendingOfframpEntry
import com.tangem.tap.network.exchangeServices.SellService import com.tangem.tap.network.exchangeServices.SellService
import com.tangem.utils.coroutines.TestingCoroutineDispatcherProvider
import io.mockk.* import io.mockk.*
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.test.runTest
import org.junit.jupiter.api.AfterEach import org.junit.jupiter.api.AfterEach
import org.junit.jupiter.api.BeforeEach import org.junit.jupiter.api.BeforeEach
import org.junit.jupiter.api.Test import org.junit.jupiter.api.Test
import org.junit.jupiter.api.TestInstance import org.junit.jupiter.api.TestInstance
import java.util.concurrent.TimeUnit
@TestInstance(TestInstance.Lifecycle.PER_CLASS) @TestInstance(TestInstance.Lifecycle.PER_CLASS)
internal class DefaultOfframpRepositoryTest { internal class DefaultOfframpRepositoryTest {
private val sellService: SellService = mockk() private val sellService: SellService = mockk()
private val repository = DefaultOfframpRepository(sellService) private val pendingStoreState = MutableStateFlow<List<PendingOfframpEntry>>(emptyList())
private val pendingOfframpStore = object : DataStore<List<PendingOfframpEntry>> {
override val data = pendingStoreState
override suspend fun updateData(
transform: suspend (t: List<PendingOfframpEntry>) -> List<PendingOfframpEntry>,
): List<PendingOfframpEntry> {
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 cryptoCurrency: CryptoCurrency = mockk()
private val fiatCurrencyCode = "USD" private val fiatCurrencyCode = "USD"
private val walletAddress = "0x1234567890abcdef" private val walletAddress = "0x1234567890abcdef"
private val requestId = "request-id-001"
private val userWalletId = UserWalletId("0011223344556677")
private val currencyId = "bitcoin"
@BeforeEach @BeforeEach
fun setUp() { fun setUp() {
mockkObject(MutableAppThemeModeHolder) mockkObject(MutableAppThemeModeHolder)
pendingStoreState.value = emptyList()
} }
@AfterEach @AfterEach
@ -42,6 +68,7 @@ internal class DefaultOfframpRepositoryTest {
fiatCurrencyName = fiatCurrencyCode, fiatCurrencyName = fiatCurrencyCode,
walletAddress = walletAddress, walletAddress = walletAddress,
isDarkTheme = false, isDarkTheme = false,
requestId = requestId,
) )
} returns expectedUrl } returns expectedUrl
@ -50,17 +77,18 @@ internal class DefaultOfframpRepositoryTest {
cryptoCurrency = cryptoCurrency, cryptoCurrency = cryptoCurrency,
fiatCurrencyCode = fiatCurrencyCode, fiatCurrencyCode = fiatCurrencyCode,
walletAddress = walletAddress, walletAddress = walletAddress,
requestId = requestId,
) )
// Assert // Assert
assertThat(result).isEqualTo(expectedUrl) assertThat(result).isEqualTo(expectedUrl)
verify(exactly = 1) { verify(exactly = 1) {
sellService.getUrl( sellService.getUrl(
cryptoCurrency = cryptoCurrency, cryptoCurrency = cryptoCurrency,
fiatCurrencyName = fiatCurrencyCode, fiatCurrencyName = fiatCurrencyCode,
walletAddress = walletAddress, walletAddress = walletAddress,
isDarkTheme = false, isDarkTheme = false,
requestId = requestId,
) )
} }
} }
@ -76,6 +104,7 @@ internal class DefaultOfframpRepositoryTest {
fiatCurrencyName = fiatCurrencyCode, fiatCurrencyName = fiatCurrencyCode,
walletAddress = walletAddress, walletAddress = walletAddress,
isDarkTheme = true, isDarkTheme = true,
requestId = requestId,
) )
} returns expectedUrl } returns expectedUrl
@ -84,17 +113,18 @@ internal class DefaultOfframpRepositoryTest {
cryptoCurrency = cryptoCurrency, cryptoCurrency = cryptoCurrency,
fiatCurrencyCode = fiatCurrencyCode, fiatCurrencyCode = fiatCurrencyCode,
walletAddress = walletAddress, walletAddress = walletAddress,
requestId = requestId,
) )
// Assert // Assert
assertThat(result).isEqualTo(expectedUrl) assertThat(result).isEqualTo(expectedUrl)
verify(exactly = 1) { verify(exactly = 1) {
sellService.getUrl( sellService.getUrl(
cryptoCurrency = cryptoCurrency, cryptoCurrency = cryptoCurrency,
fiatCurrencyName = fiatCurrencyCode, fiatCurrencyName = fiatCurrencyCode,
walletAddress = walletAddress, walletAddress = walletAddress,
isDarkTheme = true, isDarkTheme = true,
requestId = requestId,
) )
} }
} }
@ -109,6 +139,7 @@ internal class DefaultOfframpRepositoryTest {
fiatCurrencyName = fiatCurrencyCode, fiatCurrencyName = fiatCurrencyCode,
walletAddress = walletAddress, walletAddress = walletAddress,
isDarkTheme = false, isDarkTheme = false,
requestId = requestId,
) )
} returns null } returns null
@ -117,18 +148,92 @@ internal class DefaultOfframpRepositoryTest {
cryptoCurrency = cryptoCurrency, cryptoCurrency = cryptoCurrency,
fiatCurrencyCode = fiatCurrencyCode, fiatCurrencyCode = fiatCurrencyCode,
walletAddress = walletAddress, walletAddress = walletAddress,
requestId = requestId,
) )
// Assert // Assert
assertThat(result).isNull() 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()
}
}

View file

@ -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()
}
}

View file

@ -23,6 +23,8 @@ import com.tangem.utils.Provider
import dagger.assisted.Assisted import dagger.assisted.Assisted
import dagger.assisted.AssistedFactory import dagger.assisted.AssistedFactory
import dagger.assisted.AssistedInject import dagger.assisted.AssistedInject
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.launch
import kotlinx.collections.immutable.toImmutableList import kotlinx.collections.immutable.toImmutableList
@Suppress("LongParameterList") @Suppress("LongParameterList")
@ -35,6 +37,7 @@ class TokenActionsHandler @AssistedInject constructor(
private val analyticsEventHandler: AnalyticsEventHandler, private val analyticsEventHandler: AnalyticsEventHandler,
@Assisted private val currentAppCurrency: Provider<AppCurrency>, @Assisted private val currentAppCurrency: Provider<AppCurrency>,
@Assisted private val onHandleQuickAction: (action: HandledQuickAction, shouldDismiss: Boolean) -> Unit, @Assisted private val onHandleQuickAction: (action: HandledQuickAction, shouldDismiss: Boolean) -> Unit,
@Assisted private val coroutineScope: CoroutineScope,
private val isDemoCardUseCase: IsDemoCardUseCase, private val isDemoCardUseCase: IsDemoCardUseCase,
private val messageSender: UiMessageSender, private val messageSender: UiMessageSender,
) { ) {
@ -118,12 +121,15 @@ class TokenActionsHandler @AssistedInject constructor(
} }
private fun onSellClick(cryptoCurrencyData: CryptoCurrencyData) { private fun onSellClick(cryptoCurrencyData: CryptoCurrencyData) {
getOfframpUrlUseCase( coroutineScope.launch {
cryptoCurrencyStatus = cryptoCurrencyData.status, getOfframpUrlUseCase(
appCurrencyCode = currentAppCurrency().code, userWalletId = cryptoCurrencyData.userWallet.walletId,
).onRight { url -> cryptoCurrencyStatus = cryptoCurrencyData.status,
urlOpener.openUrl(url) appCurrencyCode = currentAppCurrency().code,
analyticsEventHandler.send(OfframpAnalyticsEvent.ScreenOpened) ).onRight { url ->
urlOpener.openUrl(url)
analyticsEventHandler.send(OfframpAnalyticsEvent.ScreenOpened)
}
} }
} }
@ -177,6 +183,7 @@ class TokenActionsHandler @AssistedInject constructor(
fun create( fun create(
currentAppCurrency: Provider<AppCurrency>, currentAppCurrency: Provider<AppCurrency>,
onHandleQuickAction: (HandledQuickAction, shouldDismiss: Boolean) -> Unit, onHandleQuickAction: (HandledQuickAction, shouldDismiss: Boolean) -> Unit,
coroutineScope: CoroutineScope,
): TokenActionsHandler ): TokenActionsHandler
} }

View file

@ -4,10 +4,15 @@ import arrow.core.Either
import arrow.core.raise.either import arrow.core.raise.either
import arrow.core.raise.ensure import arrow.core.raise.ensure
import com.tangem.domain.models.currency.CryptoCurrencyStatus import com.tangem.domain.models.currency.CryptoCurrencyStatus
import com.tangem.domain.models.wallet.UserWalletId
import com.tangem.domain.offramp.repository.OfframpRepository 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 * @property offrampRepository repository for offramp operations
*/ */
@ -15,20 +20,30 @@ class GetOfframpUrlUseCase(
private val offrampRepository: OfframpRepository, private val offrampRepository: OfframpRepository,
) { ) {
operator fun invoke(cryptoCurrencyStatus: CryptoCurrencyStatus, appCurrencyCode: String): Either<Error, String> = suspend operator fun invoke(
either { userWalletId: UserWalletId,
val walletAddress = cryptoCurrencyStatus.value.networkAddress?.defaultAddress?.value cryptoCurrencyStatus: CryptoCurrencyStatus,
ensure(walletAddress != null) { Error.WalletAddressNotFound } appCurrencyCode: String,
): Either<Error, String> = either<Error, String> {
val walletAddress = cryptoCurrencyStatus.value.networkAddress?.defaultAddress?.value
ensure(walletAddress != null) { Error.WalletAddressNotFound }
val url = offrampRepository.getOfframpUrl( val requestId = offrampRepository.registerPendingOfframp(
cryptoCurrency = cryptoCurrencyStatus.currency, userWalletId = userWalletId,
fiatCurrencyCode = appCurrencyCode, currencyId = cryptoCurrencyStatus.currency.id.value,
walletAddress = walletAddress, )
)
ensure(url != null) { Error.UrlNotAvailable }
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 */ /** Offramp use case errors */
sealed class Error { sealed class Error {

View file

@ -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,
)

View file

@ -1,6 +1,8 @@
package com.tangem.domain.offramp.repository package com.tangem.domain.offramp.repository
import com.tangem.domain.models.currency.CryptoCurrency 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 * Repository for offramp (sell crypto) operations
@ -13,7 +15,31 @@ interface OfframpRepository {
* @param cryptoCurrency crypto currency to sell * @param cryptoCurrency crypto currency to sell
* @param fiatCurrencyCode fiat currency code (e.g., "USD", "EUR") * @param fiatCurrencyCode fiat currency code (e.g., "USD", "EUR")
* @param walletAddress wallet address for the refund * @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 * @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?
} }

View file

@ -4,11 +4,14 @@ import com.google.common.truth.Truth.assertThat
import com.tangem.domain.models.currency.CryptoCurrency import com.tangem.domain.models.currency.CryptoCurrency
import com.tangem.domain.models.currency.CryptoCurrencyStatus import com.tangem.domain.models.currency.CryptoCurrencyStatus
import com.tangem.domain.models.network.NetworkAddress import com.tangem.domain.models.network.NetworkAddress
import com.tangem.domain.models.wallet.UserWalletId
import com.tangem.domain.offramp.repository.OfframpRepository import com.tangem.domain.offramp.repository.OfframpRepository
import io.mockk.clearMocks import io.mockk.clearMocks
import io.mockk.coEvery
import io.mockk.coVerify
import io.mockk.every import io.mockk.every
import io.mockk.mockk import io.mockk.mockk
import io.mockk.verify import kotlinx.coroutines.test.runTest
import org.junit.jupiter.api.BeforeEach import org.junit.jupiter.api.BeforeEach
import org.junit.jupiter.api.Test import org.junit.jupiter.api.Test
import org.junit.jupiter.api.TestInstance import org.junit.jupiter.api.TestInstance
@ -19,7 +22,12 @@ class GetOfframpUrlUseCaseTest {
private val offrampRepository: OfframpRepository = mockk() private val offrampRepository: OfframpRepository = mockk()
private val useCase = GetOfframpUrlUseCase(offrampRepository) 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 appCurrencyCode = "USD"
private val walletAddress = "0x1234567890abcdef" private val walletAddress = "0x1234567890abcdef"
private val expectedUrl = "https://moonpay.com/sell?address=$walletAddress" private val expectedUrl = "https://moonpay.com/sell?address=$walletAddress"
@ -27,77 +35,82 @@ class GetOfframpUrlUseCaseTest {
@BeforeEach @BeforeEach
fun resetMocks() { fun resetMocks() {
clearMocks(offrampRepository) clearMocks(offrampRepository)
coEvery { offrampRepository.registerPendingOfframp(any(), any()) } returns requestId
} }
@Test @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 // Arrange
val cryptoCurrencyStatus = createCryptoCurrencyStatus(walletAddress = walletAddress) val cryptoCurrencyStatus = createCryptoCurrencyStatus(walletAddress = walletAddress)
every { coEvery {
offrampRepository.getOfframpUrl( offrampRepository.getOfframpUrl(
cryptoCurrency = cryptoCurrency, cryptoCurrency = cryptoCurrency,
fiatCurrencyCode = appCurrencyCode, fiatCurrencyCode = appCurrencyCode,
walletAddress = walletAddress, walletAddress = walletAddress,
requestId = requestId,
) )
} returns expectedUrl } returns expectedUrl
// Act // Act
val result = useCase(cryptoCurrencyStatus, appCurrencyCode) val result = useCase(userWalletId, cryptoCurrencyStatus, appCurrencyCode)
// Assert // Assert
assertThat(result.isRight()).isTrue() assertThat(result.isRight()).isTrue()
assertThat(result.getOrNull()).isEqualTo(expectedUrl) assertThat(result.getOrNull()).isEqualTo(expectedUrl)
verify(exactly = 1) { coVerify(exactly = 1) { offrampRepository.registerPendingOfframp(userWalletId, currencyId) }
coVerify(exactly = 1) {
offrampRepository.getOfframpUrl( offrampRepository.getOfframpUrl(
cryptoCurrency = cryptoCurrency, cryptoCurrency = cryptoCurrency,
fiatCurrencyCode = appCurrencyCode, fiatCurrencyCode = appCurrencyCode,
walletAddress = walletAddress, walletAddress = walletAddress,
requestId = requestId,
) )
} }
} }
@Test @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 // Arrange
val cryptoCurrencyStatus = createCryptoCurrencyStatus(networkAddress = null) val cryptoCurrencyStatus = createCryptoCurrencyStatus(networkAddress = null)
// Act // Act
val result = useCase(cryptoCurrencyStatus, appCurrencyCode) val result = useCase(userWalletId, cryptoCurrencyStatus, appCurrencyCode)
// Assert // Assert
assertThat(result.isLeft()).isTrue() assertThat(result.isLeft()).isTrue()
assertThat(result.leftOrNull()).isEqualTo(GetOfframpUrlUseCase.Error.WalletAddressNotFound) assertThat(result.leftOrNull()).isEqualTo(GetOfframpUrlUseCase.Error.WalletAddressNotFound)
verify(exactly = 0) { coVerify(exactly = 0) { offrampRepository.registerPendingOfframp(any(), any()) }
offrampRepository.getOfframpUrl(any(), any(), any()) coVerify(exactly = 0) { offrampRepository.getOfframpUrl(any(), any(), any(), any()) }
}
} }
@Test @Test
fun `invoke should return UrlNotAvailable error when repository returns null`() { fun `invoke should return UrlNotAvailable error when repository returns null`() = runTest {
// Arrange // Arrange
val cryptoCurrencyStatus = createCryptoCurrencyStatus(walletAddress = walletAddress) val cryptoCurrencyStatus = createCryptoCurrencyStatus(walletAddress = walletAddress)
every { coEvery {
offrampRepository.getOfframpUrl( offrampRepository.getOfframpUrl(
cryptoCurrency = cryptoCurrency, cryptoCurrency = cryptoCurrency,
fiatCurrencyCode = appCurrencyCode, fiatCurrencyCode = appCurrencyCode,
walletAddress = walletAddress, walletAddress = walletAddress,
requestId = requestId,
) )
} returns null } returns null
// Act // Act
val result = useCase(cryptoCurrencyStatus, appCurrencyCode) val result = useCase(userWalletId, cryptoCurrencyStatus, appCurrencyCode)
// Assert // Assert
assertThat(result.isLeft()).isTrue() assertThat(result.isLeft()).isTrue()
assertThat(result.leftOrNull()).isEqualTo(GetOfframpUrlUseCase.Error.UrlNotAvailable) assertThat(result.leftOrNull()).isEqualTo(GetOfframpUrlUseCase.Error.UrlNotAvailable)
verify(exactly = 1) { coVerify(exactly = 1) {
offrampRepository.getOfframpUrl( offrampRepository.getOfframpUrl(
cryptoCurrency = cryptoCurrency, cryptoCurrency = cryptoCurrency,
fiatCurrencyCode = appCurrencyCode, fiatCurrencyCode = appCurrencyCode,
walletAddress = walletAddress, walletAddress = walletAddress,
requestId = requestId,
) )
} }
} }
@ -123,5 +136,4 @@ class GetOfframpUrlUseCaseTest {
every { value } returns statusValue every { value } returns statusValue
} }
} }
} }

View file

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

View file

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

View file

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

View file

@ -42,6 +42,7 @@ dependencies {
/** Domain */ /** Domain */
implementation(projects.domain.models) implementation(projects.domain.models)
implementation(projects.domain.legacy) implementation(projects.domain.legacy)
implementation(projects.domain.offramp)
implementation(projects.domain.card) implementation(projects.domain.card)
implementation(projects.domain.tokens.models) implementation(projects.domain.tokens.models)
implementation(projects.domain.tokens) 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.account.supplier.SingleAccountListSupplier
import com.tangem.domain.models.currency.CryptoCurrency import com.tangem.domain.models.currency.CryptoCurrency
import com.tangem.domain.models.wallet.UserWalletId import com.tangem.domain.models.wallet.UserWalletId
import com.tangem.domain.offramp.repository.OfframpRepository
import com.tangem.domain.wallets.usecase.GetSelectedWalletSyncUseCase import com.tangem.domain.wallets.usecase.GetSelectedWalletSyncUseCase
import com.tangem.features.send.api.deeplink.SellRedirectDeepLinkHandler import com.tangem.features.send.api.deeplink.SellRedirectDeepLinkHandler
import dagger.assisted.Assisted import dagger.assisted.Assisted
@ -20,13 +21,14 @@ import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import com.tangem.utils.logging.TangemLogger import com.tangem.utils.logging.TangemLogger
@Suppress("ComplexCondition") @Suppress("ComplexCondition", "LongParameterList")
internal class DefaultSellRedirectDeepLinkHandler @AssistedInject constructor( internal class DefaultSellRedirectDeepLinkHandler @AssistedInject constructor(
@Assisted scope: CoroutineScope, @Assisted scope: CoroutineScope,
@Assisted queryParams: Map<String, String>, @Assisted queryParams: Map<String, String>,
appRouter: AppRouter, appRouter: AppRouter,
getSelectedWalletSyncUseCase: GetSelectedWalletSyncUseCase, getSelectedWalletSyncUseCase: GetSelectedWalletSyncUseCase,
private val singleAccountListSupplier: SingleAccountListSupplier, private val singleAccountListSupplier: SingleAccountListSupplier,
private val offrampRepository: OfframpRepository,
) : SellRedirectDeepLinkHandler { ) : SellRedirectDeepLinkHandler {
init { init {
@ -35,6 +37,7 @@ internal class DefaultSellRedirectDeepLinkHandler @AssistedInject constructor(
val amount = queryParams[AMOUNT_KEY] val amount = queryParams[AMOUNT_KEY]
val destinationAddress = queryParams[DESTINATION_ADDRESS_KEY] val destinationAddress = queryParams[DESTINATION_ADDRESS_KEY]
val memo = queryParams[MEMO_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 // It is okay here, we are navigating from outside, and there is no other way to getting UserWallet
getSelectedWalletSyncUseCase() getSelectedWalletSyncUseCase()
@ -44,20 +47,30 @@ internal class DefaultSellRedirectDeepLinkHandler @AssistedInject constructor(
}, },
ifRight = { userWallet -> ifRight = { userWallet ->
if (currencyId.isNullOrEmpty() || transactionId.isNullOrEmpty() || if (currencyId.isNullOrEmpty() || transactionId.isNullOrEmpty() ||
amount.isNullOrEmpty() || destinationAddress.isNullOrEmpty() amount.isNullOrEmpty() || destinationAddress.isNullOrEmpty() ||
requestId.isNullOrEmpty()
) { ) {
TangemLogger.e( // Do not log the params: they contain the deposit address and request_id.
""" TangemLogger.e("Invalid parameters for SELL deeplink")
Invalid parameters for SELL deeplink
|- Params: $queryParams
""".trimIndent(),
)
return@fold return@fold
} }
scope.launch { 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 { 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 return@launch
} }
// Convert using universal parser to account for regional separators // 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 AMOUNT_KEY = "baseCurrencyAmount"
const val DESTINATION_ADDRESS_KEY = "depositWalletAddress" const val DESTINATION_ADDRESS_KEY = "depositWalletAddress"
const val MEMO_KEY = "depositWalletAddressTag" 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 { showErrorIfDemoModeOrElse {
val status = cryptoCurrencyStatus ?: return@showErrorIfDemoModeOrElse val status = cryptoCurrencyStatus ?: return@showErrorIfDemoModeOrElse
getOfframpUrlUseCase( modelScope.launch {
cryptoCurrencyStatus = status, getOfframpUrlUseCase(
appCurrencyCode = selectedAppCurrencyFlow.value.code, userWalletId = userWalletId,
).onRight { url -> cryptoCurrencyStatus = status,
urlOpener.openUrl(url) appCurrencyCode = selectedAppCurrencyFlow.value.code,
analyticsEventsHandler.send(OfframpAnalyticsEvent.ScreenOpened) ).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.models.wallet.UserWalletId
import com.tangem.domain.offramp.GetOfframpUrlUseCase import com.tangem.domain.offramp.GetOfframpUrlUseCase
import com.tangem.domain.onramp.model.OnrampSource 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.GetStoryContentUseCase
import com.tangem.domain.stories.models.StoryContentIds 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.NeedShowYieldSupplyDepositedWarningUseCase
import com.tangem.domain.tokens.SaveViewedTokenReceiveWarningUseCase import com.tangem.domain.tokens.SaveViewedTokenReceiveWarningUseCase
import com.tangem.domain.tokens.SaveViewedYieldSupplyWarningUseCase 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.impl.R
import com.tangem.feature.wallet.presentation.wallet.domain.unwrap 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.WalletStateController
import com.tangem.feature.wallet.presentation.wallet.state.model.WalletAlertUM import com.tangem.feature.wallet.presentation.wallet.state.model.*
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.transformers.CloseBottomSheetTransformer import com.tangem.feature.wallet.presentation.wallet.state.transformers.CloseBottomSheetTransformer
import com.tangem.feature.wallet.presentation.wallet.state.utils.WalletEventSender import com.tangem.feature.wallet.presentation.wallet.state.utils.WalletEventSender
import com.tangem.utils.coroutines.CoroutineDispatcherProvider import com.tangem.utils.coroutines.CoroutineDispatcherProvider
@ -316,9 +311,10 @@ internal class WalletCurrencyActionsClickIntentsImplementor @Inject constructor(
if (handleUnavailabilityReason(unavailabilityReason)) return if (handleUnavailabilityReason(unavailabilityReason)) return
showErrorIfDemoModeOrElse { showErrorIfDemoModeOrElse { userWallet ->
modelScope.launch(dispatchers.main) { modelScope.launch(dispatchers.main) {
getOfframpUrlUseCase( getOfframpUrlUseCase(
userWalletId = userWallet.walletId,
cryptoCurrencyStatus = cryptoCurrencyStatus, cryptoCurrencyStatus = cryptoCurrencyStatus,
appCurrencyCode = getSelectedAppCurrencyUseCase.unwrap().code, appCurrencyCode = getSelectedAppCurrencyUseCase.unwrap().code,
).onRight { url -> ).onRight { url ->
@ -480,8 +476,8 @@ internal class WalletCurrencyActionsClickIntentsImplementor @Inject constructor(
) )
} }
private fun openExplorer() { private fun openExplorer(userWallet: UserWallet) {
val userWalletId = stateHolder.getSelectedWalletId() val userWalletId = userWallet.walletId
modelScope.launch(dispatchers.main) { modelScope.launch(dispatchers.main) {
val currencyStatus = singleAccountStatusListSupplier.unwrap(userWalletId) ?: return@launch 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 val selectedWallet = getSelectedWalletSyncUseCase.unwrap() ?: return
if (selectedWallet is UserWallet.Cold && isDemoCardUseCase(cardId = selectedWallet.cardId)) { if (selectedWallet is UserWallet.Cold && isDemoCardUseCase(cardId = selectedWallet.cardId)) {
@ -557,7 +553,7 @@ internal class WalletCurrencyActionsClickIntentsImplementor @Inject constructor(
), ),
) )
} else { } else {
action() action(selectedWallet)
} }
} }