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

@ -4,10 +4,15 @@ import arrow.core.Either
import arrow.core.raise.either
import arrow.core.raise.ensure
import com.tangem.domain.models.currency.CryptoCurrencyStatus
import com.tangem.domain.models.wallet.UserWalletId
import com.tangem.domain.offramp.repository.OfframpRepository
import com.tangem.utils.logging.TangemLogger
/**
* Use case for getting offramp (sell crypto) URL
* Use case for getting offramp (sell crypto) URL.
*
* Registers a single-use `request_id` in [OfframpRepository] and embeds it into the provider redirect URL so the
* returning `redirect_sell` deeplink can be validated as a real, user-initiated sell.
*
* @property offrampRepository repository for offramp operations
*/
@ -15,20 +20,30 @@ class GetOfframpUrlUseCase(
private val offrampRepository: OfframpRepository,
) {
operator fun invoke(cryptoCurrencyStatus: CryptoCurrencyStatus, appCurrencyCode: String): Either<Error, String> =
either {
val walletAddress = cryptoCurrencyStatus.value.networkAddress?.defaultAddress?.value
ensure(walletAddress != null) { Error.WalletAddressNotFound }
suspend operator fun invoke(
userWalletId: UserWalletId,
cryptoCurrencyStatus: CryptoCurrencyStatus,
appCurrencyCode: String,
): Either<Error, String> = either<Error, String> {
val walletAddress = cryptoCurrencyStatus.value.networkAddress?.defaultAddress?.value
ensure(walletAddress != null) { Error.WalletAddressNotFound }
val url = offrampRepository.getOfframpUrl(
cryptoCurrency = cryptoCurrencyStatus.currency,
fiatCurrencyCode = appCurrencyCode,
walletAddress = walletAddress,
)
ensure(url != null) { Error.UrlNotAvailable }
val requestId = offrampRepository.registerPendingOfframp(
userWalletId = userWalletId,
currencyId = cryptoCurrencyStatus.currency.id.value,
)
url
}
val url = offrampRepository.getOfframpUrl(
cryptoCurrency = cryptoCurrencyStatus.currency,
fiatCurrencyCode = appCurrencyCode,
walletAddress = walletAddress,
requestId = requestId,
)
ensure(url != null) { Error.UrlNotAvailable }
url
}
.onLeft { TangemLogger.e("Error getting offramp URL: $it") }
/** Offramp use case errors */
sealed class Error {

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
import com.tangem.domain.models.currency.CryptoCurrency
import com.tangem.domain.models.wallet.UserWalletId
import com.tangem.domain.offramp.model.PendingOfframp
/**
* Repository for offramp (sell crypto) operations
@ -13,7 +15,31 @@ interface OfframpRepository {
* @param cryptoCurrency crypto currency to sell
* @param fiatCurrencyCode fiat currency code (e.g., "USD", "EUR")
* @param walletAddress wallet address for the refund
* @param requestId single-use nonce embedded into the provider redirect URL to authenticate the
* returning `redirect_sell` deeplink
* @return URL for offramp service or null if not available
*/
fun getOfframpUrl(cryptoCurrency: CryptoCurrency, fiatCurrencyCode: String, walletAddress: String): String?
fun getOfframpUrl(
cryptoCurrency: CryptoCurrency,
fiatCurrencyCode: String,
walletAddress: String,
requestId: String,
): String?
/**
* Registers a new app-initiated sell for [userWalletId] / [currencyId], prunes expired records, and returns a
* fresh single-use `request_id` to embed in the provider redirect URL.
*/
suspend fun registerPendingOfframp(userWalletId: UserWalletId, currencyId: String): String
/**
* Returns and removes (single-use) the pending sell matching [requestId] only when it is not expired and was
* registered for the same [userWalletId] and [currencyId]. Returns `null` otherwise, leaving a non-matching
* record untouched so a tampered redirect cannot burn a legitimate pending sell.
*/
suspend fun consumePendingOfframp(
requestId: String,
userWalletId: UserWalletId,
currencyId: String,
): PendingOfframp?
}

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