Updated on 2026-08-14
This commit is contained in:
parent
7b4593acee
commit
223051955f
22 changed files with 606 additions and 95 deletions
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
||||
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?
|
||||
}
|
||||
|
|
@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue