Updated on 2026-08-14

This commit is contained in:
Tangem 2026-02-15 11:18:11 +04:00
parent b6ab30c56e
commit 040b5f0cf6
11 changed files with 404 additions and 0 deletions

View file

@ -152,6 +152,7 @@ dependencies {
implementation(projects.domain.manageTokens)
implementation(projects.domain.nft)
implementation(projects.domain.nft.models)
implementation(projects.domain.offramp)
implementation(projects.domain.onramp)
implementation(projects.domain.promo)
implementation(projects.domain.promo.models)

View file

@ -0,0 +1,29 @@
package com.tangem.tap.data
import com.tangem.domain.models.currency.CryptoCurrency
import com.tangem.domain.offramp.repository.OfframpRepository
import com.tangem.tap.common.apptheme.MutableAppThemeModeHolder
import com.tangem.tap.network.exchangeServices.SellService
/**
* Default implementation of [OfframpRepository]
*
* @property sellService sell service for getting offramp URL
*/
internal class DefaultOfframpRepository(
private val sellService: SellService,
) : OfframpRepository {
override fun getOfframpUrl(
cryptoCurrency: CryptoCurrency,
fiatCurrencyCode: String,
walletAddress: String,
): String? {
return sellService.getUrl(
cryptoCurrency = cryptoCurrency,
fiatCurrencyName = fiatCurrencyCode,
walletAddress = walletAddress,
isDarkTheme = MutableAppThemeModeHolder.isDarkThemeActive,
)
}
}

View file

@ -1,9 +1,13 @@
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.promo.PromoRepository
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
@ -275,4 +279,16 @@ internal object OnrampDomainModule {
promoRepository = promoRepository,
)
}
@Provides
@Singleton
fun provideOfframpRepository(sellService: SellService): OfframpRepository {
return DefaultOfframpRepository(sellService)
}
@Provides
@Singleton
fun provideGetOfframpUrlUseCase(offrampRepository: OfframpRepository): GetOfframpUrlUseCase {
return GetOfframpUrlUseCase(offrampRepository)
}
}

View file

@ -0,0 +1,134 @@
package com.tangem.tap.data
import com.google.common.truth.Truth.assertThat
import com.tangem.domain.models.currency.CryptoCurrency
import com.tangem.tap.common.apptheme.MutableAppThemeModeHolder
import com.tangem.tap.network.exchangeServices.SellService
import io.mockk.*
import org.junit.jupiter.api.AfterEach
import org.junit.jupiter.api.BeforeEach
import org.junit.jupiter.api.Test
import org.junit.jupiter.api.TestInstance
@TestInstance(TestInstance.Lifecycle.PER_CLASS)
internal class DefaultOfframpRepositoryTest {
private val sellService: SellService = mockk()
private val repository = DefaultOfframpRepository(sellService)
private val cryptoCurrency: CryptoCurrency = mockk()
private val fiatCurrencyCode = "USD"
private val walletAddress = "0x1234567890abcdef"
@BeforeEach
fun setUp() {
mockkObject(MutableAppThemeModeHolder)
}
@AfterEach
fun tearDown() {
clearMocks(sellService)
unmockkObject(MutableAppThemeModeHolder)
}
@Test
fun `getOfframpUrl should return url when sellService returns url with light theme`() {
// Arrange
val expectedUrl = "https://moonpay.com/sell?address=$walletAddress&theme=light"
every { MutableAppThemeModeHolder.isDarkThemeActive } returns false
every {
sellService.getUrl(
cryptoCurrency = cryptoCurrency,
fiatCurrencyName = fiatCurrencyCode,
walletAddress = walletAddress,
isDarkTheme = false,
)
} returns expectedUrl
// Act
val result = repository.getOfframpUrl(
cryptoCurrency = cryptoCurrency,
fiatCurrencyCode = fiatCurrencyCode,
walletAddress = walletAddress,
)
// Assert
assertThat(result).isEqualTo(expectedUrl)
verify(exactly = 1) {
sellService.getUrl(
cryptoCurrency = cryptoCurrency,
fiatCurrencyName = fiatCurrencyCode,
walletAddress = walletAddress,
isDarkTheme = false,
)
}
}
@Test
fun `getOfframpUrl should return url when sellService returns url with dark theme`() {
// Arrange
val expectedUrl = "https://moonpay.com/sell?address=$walletAddress&theme=dark"
every { MutableAppThemeModeHolder.isDarkThemeActive } returns true
every {
sellService.getUrl(
cryptoCurrency = cryptoCurrency,
fiatCurrencyName = fiatCurrencyCode,
walletAddress = walletAddress,
isDarkTheme = true,
)
} returns expectedUrl
// Act
val result = repository.getOfframpUrl(
cryptoCurrency = cryptoCurrency,
fiatCurrencyCode = fiatCurrencyCode,
walletAddress = walletAddress,
)
// Assert
assertThat(result).isEqualTo(expectedUrl)
verify(exactly = 1) {
sellService.getUrl(
cryptoCurrency = cryptoCurrency,
fiatCurrencyName = fiatCurrencyCode,
walletAddress = walletAddress,
isDarkTheme = true,
)
}
}
@Test
fun `getOfframpUrl should return null when sellService returns null`() {
// Arrange
every { MutableAppThemeModeHolder.isDarkThemeActive } returns false
every {
sellService.getUrl(
cryptoCurrency = cryptoCurrency,
fiatCurrencyName = fiatCurrencyCode,
walletAddress = walletAddress,
isDarkTheme = false,
)
} returns null
// Act
val result = repository.getOfframpUrl(
cryptoCurrency = cryptoCurrency,
fiatCurrencyCode = fiatCurrencyCode,
walletAddress = walletAddress,
)
// Assert
assertThat(result).isNull()
verify(exactly = 1) {
sellService.getUrl(
cryptoCurrency = cryptoCurrency,
fiatCurrencyName = fiatCurrencyCode,
walletAddress = walletAddress,
isDarkTheme = false,
)
}
}
}

View file

@ -0,0 +1,17 @@
package com.tangem.core.analytics.models.event
import com.tangem.core.analytics.models.AnalyticsEvent
/**
* Offramp (withdraw/sell) analytics events
*/
sealed class OfframpAnalyticsEvent(
event: String,
params: Map<String, String> = emptyMap(),
) : AnalyticsEvent(category = "Token / Withdraw", event = event, params = params) {
/**
* Withdraw screen opened event
*/
data object ScreenOpened : OfframpAnalyticsEvent("Withdraw Screen Opened")
}

1
domain/offramp/.gitignore vendored Normal file
View file

@ -0,0 +1 @@
/build

View file

@ -0,0 +1,18 @@
plugins {
alias(deps.plugins.kotlin.jvm)
id("configuration")
}
tasks.withType<Test>().configureEach {
useJUnitPlatform()
}
dependencies {
/** Domain modules */
api(projects.domain.core)
api(projects.domain.models)
/** Test libraries */
testImplementation(projects.test.core)
testRuntimeOnly(deps.test.junit5.engine)
}

View file

@ -0,0 +1,41 @@
package com.tangem.domain.offramp
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.offramp.repository.OfframpRepository
/**
* Use case for getting offramp (sell crypto) URL
*
* @property offrampRepository repository for offramp operations
*/
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 }
val url = offrampRepository.getOfframpUrl(
cryptoCurrency = cryptoCurrencyStatus.currency,
fiatCurrencyCode = appCurrencyCode,
walletAddress = walletAddress,
)
ensure(url != null) { Error.UrlNotAvailable }
url
}
/** Offramp use case errors */
sealed class Error {
/** Wallet address not found in currency status */
data object WalletAddressNotFound : Error()
/** Offramp URL is not available for this currency */
data object UrlNotAvailable : Error()
}
}

View file

@ -0,0 +1,19 @@
package com.tangem.domain.offramp.repository
import com.tangem.domain.models.currency.CryptoCurrency
/**
* Repository for offramp (sell crypto) operations
*/
interface OfframpRepository {
/**
* Get offramp (sell) URL for the given cryptocurrency
*
* @param cryptoCurrency crypto currency to sell
* @param fiatCurrencyCode fiat currency code (e.g., "USD", "EUR")
* @param walletAddress wallet address for the refund
* @return URL for offramp service or null if not available
*/
fun getOfframpUrl(cryptoCurrency: CryptoCurrency, fiatCurrencyCode: String, walletAddress: String): String?
}

View file

@ -0,0 +1,127 @@
package com.tangem.domain.offramp
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.offramp.repository.OfframpRepository
import io.mockk.clearMocks
import io.mockk.every
import io.mockk.mockk
import io.mockk.verify
import org.junit.jupiter.api.BeforeEach
import org.junit.jupiter.api.Test
import org.junit.jupiter.api.TestInstance
@TestInstance(TestInstance.Lifecycle.PER_CLASS)
class GetOfframpUrlUseCaseTest {
private val offrampRepository: OfframpRepository = mockk()
private val useCase = GetOfframpUrlUseCase(offrampRepository)
private val cryptoCurrency: CryptoCurrency = mockk()
private val appCurrencyCode = "USD"
private val walletAddress = "0x1234567890abcdef"
private val expectedUrl = "https://moonpay.com/sell?address=$walletAddress"
@BeforeEach
fun resetMocks() {
clearMocks(offrampRepository)
}
@Test
fun `invoke should return url when wallet address and url are available`() {
// Arrange
val cryptoCurrencyStatus = createCryptoCurrencyStatus(walletAddress = walletAddress)
every {
offrampRepository.getOfframpUrl(
cryptoCurrency = cryptoCurrency,
fiatCurrencyCode = appCurrencyCode,
walletAddress = walletAddress,
)
} returns expectedUrl
// Act
val result = useCase(cryptoCurrencyStatus, appCurrencyCode)
// Assert
assertThat(result.isRight()).isTrue()
assertThat(result.getOrNull()).isEqualTo(expectedUrl)
verify(exactly = 1) {
offrampRepository.getOfframpUrl(
cryptoCurrency = cryptoCurrency,
fiatCurrencyCode = appCurrencyCode,
walletAddress = walletAddress,
)
}
}
@Test
fun `invoke should return WalletAddressNotFound error when network address is null`() {
// Arrange
val cryptoCurrencyStatus = createCryptoCurrencyStatus(networkAddress = null)
// Act
val result = useCase(cryptoCurrencyStatus, appCurrencyCode)
// Assert
assertThat(result.isLeft()).isTrue()
assertThat(result.leftOrNull()).isEqualTo(GetOfframpUrlUseCase.Error.WalletAddressNotFound)
verify(exactly = 0) {
offrampRepository.getOfframpUrl(any(), any(), any())
}
}
@Test
fun `invoke should return UrlNotAvailable error when repository returns null`() {
// Arrange
val cryptoCurrencyStatus = createCryptoCurrencyStatus(walletAddress = walletAddress)
every {
offrampRepository.getOfframpUrl(
cryptoCurrency = cryptoCurrency,
fiatCurrencyCode = appCurrencyCode,
walletAddress = walletAddress,
)
} returns null
// Act
val result = useCase(cryptoCurrencyStatus, appCurrencyCode)
// Assert
assertThat(result.isLeft()).isTrue()
assertThat(result.leftOrNull()).isEqualTo(GetOfframpUrlUseCase.Error.UrlNotAvailable)
verify(exactly = 1) {
offrampRepository.getOfframpUrl(
cryptoCurrency = cryptoCurrency,
fiatCurrencyCode = appCurrencyCode,
walletAddress = walletAddress,
)
}
}
private fun createCryptoCurrencyStatus(
walletAddress: String? = null,
networkAddress: NetworkAddress? = null,
): CryptoCurrencyStatus {
val resolvedNetworkAddress = networkAddress ?: walletAddress?.let { address ->
mockk<NetworkAddress> {
every { defaultAddress } returns mockk {
every { value } returns address
}
}
}
val statusValue: CryptoCurrencyStatus.Value = mockk {
every { this@mockk.networkAddress } returns resolvedNetworkAddress
}
return mockk {
every { currency } returns cryptoCurrency
every { value } returns statusValue
}
}
}

View file

@ -349,6 +349,7 @@ include(":domain:manage-tokens")
include(":domain:manage-tokens:models")
include(":domain:onramp")
include(":domain:onramp:models")
include(":domain:offramp")
include(":domain:promo")
include(":domain:promo:models")
include(":domain:nft")