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
import androidx.datastore.core.DataStore
import com.tangem.domain.models.currency.CryptoCurrency
import com.tangem.domain.models.wallet.UserWalletId
import com.tangem.domain.offramp.model.PendingOfframp
import com.tangem.domain.offramp.repository.OfframpRepository
import com.tangem.tap.common.apptheme.MutableAppThemeModeHolder
import com.tangem.tap.data.converter.PendingOfframpEntryConverter
import com.tangem.tap.data.model.PendingOfframpEntry
import com.tangem.tap.network.exchangeServices.SellService
import com.tangem.utils.coroutines.CoroutineDispatcherProvider
import kotlinx.coroutines.withContext
import java.util.UUID
import java.util.concurrent.TimeUnit
/**
* Default implementation of [OfframpRepository]
* Default implementation of [OfframpRepository].
*
* @property sellService sell service for getting offramp URL
* @property pendingOfframpStore dedicated kotlinx-serialized store of app-initiated sells
* @property dispatchers coroutine dispatchers provider for IO operations
*/
internal class DefaultOfframpRepository(
private val sellService: SellService,
private val pendingOfframpStore: DataStore<List<PendingOfframpEntry>>,
private val dispatchers: CoroutineDispatcherProvider,
) : OfframpRepository {
private val pendingOfframpConverter = PendingOfframpEntryConverter()
override fun getOfframpUrl(
cryptoCurrency: CryptoCurrency,
fiatCurrencyCode: String,
walletAddress: String,
requestId: String,
): String? {
return sellService.getUrl(
cryptoCurrency = cryptoCurrency,
fiatCurrencyName = fiatCurrencyCode,
walletAddress = walletAddress,
isDarkTheme = MutableAppThemeModeHolder.isDarkThemeActive,
requestId = requestId,
)
}
override suspend fun registerPendingOfframp(userWalletId: UserWalletId, currencyId: String): String =
withContext(dispatchers.io) {
val requestId = UUID.randomUUID().toString()
val now = System.currentTimeMillis()
pendingOfframpStore.updateData { stored ->
stored.filterNotExpired(now) + PendingOfframpEntry(
requestId = requestId,
userWalletId = userWalletId.stringValue,
currencyId = currencyId,
createdAt = now,
)
}
requestId
}
override suspend fun consumePendingOfframp(
requestId: String,
userWalletId: UserWalletId,
currencyId: String,
): PendingOfframp? = withContext(dispatchers.io) {
val now = System.currentTimeMillis()
var matched: PendingOfframpEntry? = null
pendingOfframpStore.updateData { stored ->
matched = stored.firstOrNull { entry ->
entry.requestId == requestId &&
entry.userWalletId == userWalletId.stringValue &&
entry.currencyId == currencyId &&
now - entry.createdAt < EXPIRY_MS
}
// Remove only the fully-matched record (single-use); always prune expired ones. A request_id that
// matches but with a mismatched wallet/currency is left intact so a tampered redirect cannot burn it.
stored.filter { it != matched }.filterNotExpired(now)
}
matched?.let(pendingOfframpConverter::convert)
}
private fun List<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
import com.tangem.domain.offramp.GetOfframpUrlUseCase
import com.tangem.domain.offramp.repository.OfframpRepository
import com.tangem.domain.onramp.*
import com.tangem.domain.onramp.repositories.*
import com.tangem.domain.settings.repositories.SettingsRepository
import com.tangem.tap.data.DefaultOfframpRepository
import com.tangem.tap.network.exchangeServices.SellService
import dagger.Module
import dagger.Provides
import dagger.hilt.InstallIn
@ -270,16 +266,4 @@ internal object OnrampDomainModule {
settingsRepository = settingsRepository,
)
}
@Provides
@Singleton
fun provideOfframpRepository(sellService: SellService): OfframpRepository {
return DefaultOfframpRepository(sellService)
}
@Provides
@Singleton
fun provideGetOfframpUrlUseCase(offrampRepository: OfframpRepository): GetOfframpUrlUseCase {
return GetOfframpUrlUseCase(offrampRepository)
}
}

View file

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

View file

@ -138,6 +138,7 @@ class MoonPayService(
fiatCurrencyName: String,
walletAddress: String,
isDarkTheme: Boolean,
requestId: String,
): String? {
val blockchain = cryptoCurrency.network.toBlockchain()
if (blockchain.isTestnet()) return blockchain.getTestnetTopUpUrl()
@ -165,7 +166,12 @@ class MoonPayService(
.appendQueryParameter("apiKey", apiKey)
.appendQueryParameter("baseCurrencyCode", moonpayCurrency.currencyCode.uppercase())
.appendQueryParameter("refundWalletAddress", walletAddress)
.appendQueryParameter("redirectURL", "tangem://redirect_sell?currency_id=${cryptoCurrency.id.value}")
// request_id authenticates the returning redirect_sell deeplink. It must be added to
// redirectURL BEFORE createSignature below so it is covered by the MoonPay URL signature.
.appendQueryParameter(
"redirectURL",
"tangem://redirect_sell?currency_id=${cryptoCurrency.id.value}&request_id=$requestId",
)
if (isDarkTheme) uri.appendQueryParameter("theme", "dark")