Updated on 2026-08-14
This commit is contained in:
parent
7b4593acee
commit
223051955f
22 changed files with 606 additions and 95 deletions
|
|
@ -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)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -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,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
@ -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,
|
||||||
|
)
|
||||||
|
|
@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
@ -20,5 +20,6 @@ interface SellService {
|
||||||
fiatCurrencyName: String,
|
fiatCurrencyName: String,
|
||||||
walletAddress: String,
|
walletAddress: String,
|
||||||
isDarkTheme: Boolean,
|
isDarkTheme: Boolean,
|
||||||
|
requestId: String,
|
||||||
): String?
|
): String?
|
||||||
}
|
}
|
||||||
|
|
@ -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")
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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 {
|
||||||
|
|
|
||||||
|
|
@ -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,
|
||||||
|
)
|
||||||
|
|
@ -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?
|
||||||
}
|
}
|
||||||
|
|
@ -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
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -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()
|
||||||
|
|
|
||||||
|
|
@ -151,6 +151,7 @@ internal class MarketsPortfolioModel @Inject constructor(
|
||||||
)
|
)
|
||||||
configureReceiveAddresses(handledAction)
|
configureReceiveAddresses(handledAction)
|
||||||
},
|
},
|
||||||
|
coroutineScope = modelScope,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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 ->
|
||||||
|
|
|
||||||
|
|
@ -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)
|
||||||
|
|
|
||||||
|
|
@ -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"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -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"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue