Updated on 2026-08-14

This commit is contained in:
Tangem 2026-06-01 14:01:06 +03:00
commit bc47de97b7
997 changed files with 45685 additions and 12520 deletions

View file

@ -17,6 +17,7 @@ dependencies {
implementation(projects.core.ui)
implementation(projects.core.utils)
implementation(projects.libs.blockchainSdk)
implementation(projects.libs.crypto)
/** Domain */
implementation(projects.domain.account.status)

View file

@ -11,5 +11,6 @@ dependencies {
// region Other libraries
implementation(deps.kotlin.serialization)
api(deps.kotlin.datetime)
}

View file

@ -0,0 +1,24 @@
package com.tangem.domain.yield.supply.models
import kotlinx.datetime.Instant
sealed interface YieldBoostPromo {
data object None : YieldBoostPromo
data class Active(
val tokens: List<PromoToken>,
val timeline: Timeline,
val link: String?,
) : YieldBoostPromo {
data class PromoToken(
val contractAddress: String,
val tokenSymbol: String,
val tokenName: String,
val networkId: String,
)
data class Timeline(val start: Instant, val end: Instant)
}
}

View file

@ -0,0 +1,31 @@
package com.tangem.domain.yield.supply.models
import kotlinx.datetime.Instant
sealed interface YieldBoostStatus {
data object NotStarted : YieldBoostStatus
/**
* User is enrolled in the boost (backend `active` or `completed`).
*
* The boost block on the active screen is driven entirely by [qualificationEndDate], which the backend
* computes as the end of the bonus-accrual period:
* - `null` nothing is shown;
* - in the future days left until the date;
* - reached / passed awaiting payout.
*/
data class Enrolled(
val tokenName: String,
val networkId: String,
val moduleAddress: String,
val userAddress: String,
val contractAddress: String,
val qualificationEndDate: Instant?,
) : YieldBoostStatus
data class Disqualified(val reason: Reason) : YieldBoostStatus {
enum class Reason { FROD, LESS_THAN_1_USD, CLOSED, UNKNOWN }
}
}

View file

@ -0,0 +1,20 @@
package com.tangem.domain.yield.supply.promo
import com.tangem.domain.models.wallet.UserWalletId
import com.tangem.domain.yield.supply.models.YieldBoostPromo
import com.tangem.domain.yield.supply.models.YieldBoostStatus
/**
* Backend yield-boost promo plumbing.
*
* Implementations keep an in-memory cache keyed by [UserWalletId]. On a refresh failure the cached
* value is returned. With an empty cache the call throws use cases swallow that to "hide UI".
*/
interface YieldPromoRepository {
@Throws
suspend fun getYieldBoostPromo(userWalletId: UserWalletId, forceRefresh: Boolean = false): YieldBoostPromo
@Throws
suspend fun getYieldBoostStatus(userWalletId: UserWalletId, forceRefresh: Boolean = false): YieldBoostStatus
}

View file

@ -0,0 +1,16 @@
package com.tangem.domain.yield.supply.promo.usecase
import java.math.BigDecimal
/**
* Pure boosted APY calculation. Hard-coded x3 coefficient single place to swap when the backend
* starts returning the coefficient explicitly.
*/
class GetBoostedApyUseCase {
operator fun invoke(baseApy: BigDecimal): BigDecimal = baseApy.multiply(BOOST_MULTIPLIER)
private companion object {
val BOOST_MULTIPLIER: BigDecimal = BigDecimal(3)
}
}

View file

@ -0,0 +1,18 @@
package com.tangem.domain.yield.supply.promo.usecase
import arrow.core.Either
import com.tangem.domain.models.wallet.UserWalletId
import com.tangem.domain.yield.supply.models.YieldBoostStatus
import com.tangem.domain.yield.supply.promo.YieldPromoRepository
class GetYieldBoostStatusUseCase(
private val repository: YieldPromoRepository,
) {
suspend operator fun invoke(
userWalletId: UserWalletId,
forceRefresh: Boolean = false,
): Either<Throwable, YieldBoostStatus> = Either.catch {
repository.getYieldBoostStatus(userWalletId, forceRefresh)
}
}

View file

@ -0,0 +1,47 @@
package com.tangem.domain.yield.supply.promo.usecase
import arrow.core.Either
import com.tangem.domain.models.currency.CryptoCurrency
import com.tangem.domain.models.wallet.UserWalletId
import com.tangem.domain.yield.supply.models.YieldBoostPromo
import com.tangem.domain.yield.supply.models.YieldBoostStatus
import com.tangem.domain.yield.supply.promo.YieldPromoRepository
import com.tangem.lib.crypto.BlockchainUtils
/**
* Returns `true` iff the given token is in the active promo list AND the user has not started boost yet.
*
* Short-circuits to `false` on:
* - non-Token currency
* - promo `None` (no active promo)
* - status not `NotStarted` (already Active / Completed / Disqualified)
*
* Any underlying repository failure surfaces as `Either.Left`.
*
* Feature-toggle and redesign-flag gating is the caller's responsibility keep this use case
* decoupled from feature-layer toggles to avoid the cyclic dependency `domain -> features`.
*/
class IsYieldBoostPromoEnabledForTokenUseCase(
private val repository: YieldPromoRepository,
) {
suspend operator fun invoke(
userWalletId: UserWalletId,
cryptoCurrency: CryptoCurrency,
): Either<Throwable, Boolean> = Either.catch {
val token = cryptoCurrency as? CryptoCurrency.Token ?: return@catch false
val promo = repository.getYieldBoostPromo(userWalletId)
if (promo !is YieldBoostPromo.Active) return@catch false
val shouldIgnoreCase = BlockchainUtils.isCaseInsensitiveContractAddress(token.network.rawId)
val isTokenMatched = promo.tokens.any { promoToken ->
promoToken.contractAddress.equals(token.contractAddress, ignoreCase = shouldIgnoreCase) &&
promoToken.networkId == token.network.rawId
}
if (!isTokenMatched) return@catch false
val status = repository.getYieldBoostStatus(userWalletId)
status is YieldBoostStatus.NotStarted
}
}

View file

@ -0,0 +1,31 @@
package com.tangem.domain.yield.supply.promo.usecase
import arrow.core.Either
import com.tangem.domain.models.wallet.UserWalletId
import com.tangem.domain.yield.supply.models.YieldBoostPromo
import com.tangem.domain.yield.supply.models.YieldBoostStatus
import com.tangem.domain.yield.supply.promo.YieldPromoRepository
/**
* Returns `true` iff the main wallet boost banner should be shown:
* - promo is `Active` server-side
* - status is `NotStarted`
*
* Token ownership is intentionally NOT checked the banner is shown to every eligible wallet
* regardless of whether it currently holds a promo token.
*
* Any repository failure surfaces as `Either.Left` never assume eligibility on uncertainty.
* Feature-toggle / redesign / "user dismissed" gating is the caller's responsibility.
*/
class ShouldShowYieldBoostMainBannerUseCase(
private val repository: YieldPromoRepository,
) {
suspend operator fun invoke(userWalletId: UserWalletId): Either<Throwable, Boolean> = Either.catch {
val promo = repository.getYieldBoostPromo(userWalletId)
if (promo !is YieldBoostPromo.Active) return@catch false
val status = repository.getYieldBoostStatus(userWalletId)
status is YieldBoostStatus.NotStarted
}
}

View file

@ -316,9 +316,9 @@ class YieldSupplyEstimateEnterFeeUseCaseTest {
val deployFee = txs.first().fee as Fee.Ethereum.EIP1559
val approveFee = txs[1].fee as Fee.Ethereum.EIP1559
val enterFee = txs.last().fee as Fee.Ethereum.EIP1559
Truth.assertThat(deployFee.gasLimit).isEqualTo(BigInteger.valueOf(1_200))
Truth.assertThat(approveFee.gasLimit).isEqualTo(BigInteger.valueOf(2_400))
Truth.assertThat(enterFee.gasLimit).isEqualTo(BigInteger.valueOf(3_600))
Truth.assertThat(deployFee.gasLimit).isEqualTo(BigInteger.valueOf(1_400))
Truth.assertThat(approveFee.gasLimit).isEqualTo(BigInteger.valueOf(2_800))
Truth.assertThat(enterFee.gasLimit).isEqualTo(BigInteger.valueOf(4_200))
}
@Test
@ -355,9 +355,9 @@ class YieldSupplyEstimateEnterFeeUseCaseTest {
val deployFee = txs.first().fee as Fee.Ethereum.Legacy
val approveFee = txs[1].fee as Fee.Ethereum.Legacy
val enterFee = txs.last().fee as Fee.Ethereum.Legacy
Truth.assertThat(deployFee.gasLimit).isEqualTo(BigInteger.valueOf(1_200))
Truth.assertThat(approveFee.gasLimit).isEqualTo(BigInteger.valueOf(2_400))
Truth.assertThat(enterFee.gasLimit).isEqualTo(BigInteger.valueOf(3_600))
Truth.assertThat(deployFee.gasLimit).isEqualTo(BigInteger.valueOf(1_400))
Truth.assertThat(approveFee.gasLimit).isEqualTo(BigInteger.valueOf(2_800))
Truth.assertThat(enterFee.gasLimit).isEqualTo(BigInteger.valueOf(4_200))
}
private fun getDeployTx() = uncompiled(

View file

@ -0,0 +1,38 @@
package com.tangem.domain.yield.supply.promo.usecase
import com.google.common.truth.Truth.assertThat
import org.junit.jupiter.api.Test
import java.math.BigDecimal
class GetBoostedApyUseCaseTest {
private val useCase = GetBoostedApyUseCase()
@Test
fun `GIVEN base apy 5_1 WHEN invoke THEN returns 15_3`() {
val result = useCase(BigDecimal("5.1"))
assertThat(result).isEqualTo(BigDecimal("15.3"))
}
@Test
fun `GIVEN base apy 0 WHEN invoke THEN returns 0`() {
val result = useCase(BigDecimal.ZERO)
assertThat(result).isEqualTo(BigDecimal.ZERO.multiply(BigDecimal(3)))
}
@Test
fun `GIVEN base apy 4_99 WHEN invoke THEN returns 14_97`() {
val result = useCase(BigDecimal("4.99"))
assertThat(result).isEqualTo(BigDecimal("14.97"))
}
@Test
fun `GIVEN base apy 100 WHEN invoke THEN returns 300`() {
val result = useCase(BigDecimal("100"))
assertThat(result).isEqualTo(BigDecimal("300"))
}
}

View file

@ -0,0 +1,224 @@
package com.tangem.domain.yield.supply.promo.usecase
import com.google.common.truth.Truth.assertThat
import com.tangem.domain.models.currency.CryptoCurrency
import com.tangem.domain.models.network.Network
import com.tangem.domain.models.wallet.UserWalletId
import com.tangem.domain.yield.supply.models.YieldBoostPromo
import com.tangem.domain.yield.supply.models.YieldBoostStatus
import com.tangem.domain.yield.supply.promo.YieldPromoRepository
import io.mockk.coEvery
import io.mockk.mockk
import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.test.runTest
import kotlinx.datetime.Instant
import org.junit.jupiter.api.BeforeEach
import org.junit.jupiter.api.Test
@OptIn(ExperimentalCoroutinesApi::class)
class IsYieldBoostPromoEnabledForTokenUseCaseTest {
private val repository: YieldPromoRepository = mockk()
private lateinit var useCase: IsYieldBoostPromoEnabledForTokenUseCase
private val userWalletId = UserWalletId("abcdef012345")
private val contractAddress = "0xa0b86991c6218b36c1d19d4a2e9eb0ce3606eb48"
private val networkRawId = "ethereum"
@BeforeEach
fun setUp() {
useCase = IsYieldBoostPromoEnabledForTokenUseCase(repository = repository)
}
@Test
fun `GIVEN currency is coin WHEN invoke THEN returns Right(false)`() = runTest {
val coin = createCoin()
val result = useCase(userWalletId, coin)
assertThat(result.getOrNull()).isFalse()
}
@Test
fun `GIVEN promo is None WHEN invoke THEN returns Right(false)`() = runTest {
val token = createToken()
coEvery { repository.getYieldBoostPromo(userWalletId, false) } returns YieldBoostPromo.None
val result = useCase(userWalletId, token)
assertThat(result.getOrNull()).isFalse()
}
@Test
fun `GIVEN promo repository throws WHEN invoke THEN returns Left`() = runTest {
val token = createToken()
coEvery { repository.getYieldBoostPromo(userWalletId, false) } throws RuntimeException("net")
val result = useCase(userWalletId, token)
assertThat(result.isLeft()).isTrue()
}
@Test
fun `GIVEN token not in promo list WHEN invoke THEN returns Right(false)`() = runTest {
val token = createToken(contractAddress = "0xdifferent")
coEvery { repository.getYieldBoostPromo(userWalletId, false) } returns activePromo()
val result = useCase(userWalletId, token)
assertThat(result.getOrNull()).isFalse()
}
@Test
fun `GIVEN network mismatch WHEN invoke THEN returns Right(false)`() = runTest {
val token = createToken(networkRawId = "polygon")
coEvery { repository.getYieldBoostPromo(userWalletId, false) } returns activePromo()
val result = useCase(userWalletId, token)
assertThat(result.getOrNull()).isFalse()
}
@Test
fun `GIVEN status repository throws WHEN invoke THEN returns Left`() = runTest {
val token = createToken()
coEvery { repository.getYieldBoostPromo(userWalletId, false) } returns activePromo()
coEvery { repository.getYieldBoostStatus(userWalletId, false) } throws RuntimeException("net")
val result = useCase(userWalletId, token)
assertThat(result.isLeft()).isTrue()
}
@Test
fun `GIVEN status is Enrolled WHEN invoke THEN returns Right(false)`() = runTest {
val token = createToken()
coEvery { repository.getYieldBoostPromo(userWalletId, false) } returns activePromo()
coEvery { repository.getYieldBoostStatus(userWalletId, false) } returns enrolledStatus()
val result = useCase(userWalletId, token)
assertThat(result.getOrNull()).isFalse()
}
@Test
fun `GIVEN status is Disqualified WHEN invoke THEN returns Right(false)`() = runTest {
val token = createToken()
coEvery { repository.getYieldBoostPromo(userWalletId, false) } returns activePromo()
coEvery { repository.getYieldBoostStatus(userWalletId, false) } returns
YieldBoostStatus.Disqualified(YieldBoostStatus.Disqualified.Reason.FROD)
val result = useCase(userWalletId, token)
assertThat(result.getOrNull()).isFalse()
}
@Test
fun `GIVEN status is NotStarted and token matches WHEN invoke THEN returns Right(true)`() = runTest {
val token = createToken()
coEvery { repository.getYieldBoostPromo(userWalletId, false) } returns activePromo()
coEvery { repository.getYieldBoostStatus(userWalletId, false) } returns YieldBoostStatus.NotStarted
val result = useCase(userWalletId, token)
assertThat(result.getOrNull()).isTrue()
}
@Test
fun `GIVEN contract address differs only in case on EVM WHEN invoke THEN returns Right(true)`() = runTest {
val token = createToken(contractAddress = contractAddress.uppercase())
coEvery { repository.getYieldBoostPromo(userWalletId, false) } returns activePromo()
coEvery { repository.getYieldBoostStatus(userWalletId, false) } returns YieldBoostStatus.NotStarted
val result = useCase(userWalletId, token)
assertThat(result.getOrNull()).isTrue()
}
private fun activePromo() = YieldBoostPromo.Active(
tokens = listOf(
YieldBoostPromo.Active.PromoToken(
contractAddress = contractAddress,
tokenSymbol = "USDC",
tokenName = "USD Coin",
networkId = networkRawId,
),
),
timeline = YieldBoostPromo.Active.Timeline(
start = Instant.parse("2026-01-01T00:00:00Z"),
end = Instant.parse("2027-01-01T00:00:00Z"),
),
link = null,
)
private fun enrolledStatus() = YieldBoostStatus.Enrolled(
tokenName = "USD Coin",
networkId = networkRawId,
moduleAddress = "0xmodule",
userAddress = "0xuser",
contractAddress = contractAddress,
qualificationEndDate = Instant.parse("2026-06-01T00:00:00Z"),
)
private fun createToken(
contractAddress: String = this.contractAddress,
networkRawId: String = this.networkRawId,
): CryptoCurrency.Token {
val derivationPath = Network.DerivationPath.None
val network = Network(
id = Network.ID(value = networkRawId, derivationPath = derivationPath),
name = networkRawId,
currencySymbol = networkRawId.take(3).uppercase(),
derivationPath = derivationPath,
isTestnet = false,
standardType = Network.StandardType.Unspecified("UNSPECIFIED"),
hasFiatFeeRate = true,
canHandleTokens = true,
transactionExtrasType = Network.TransactionExtrasType.NONE,
nameResolvingType = Network.NameResolvingType.NONE,
)
return CryptoCurrency.Token(
id = CryptoCurrency.ID(
prefix = CryptoCurrency.ID.Prefix.TOKEN_PREFIX,
body = CryptoCurrency.ID.Body.NetworkId(networkRawId),
suffix = CryptoCurrency.ID.Suffix.RawID(networkRawId),
),
network = network,
name = "USDC",
symbol = "USDC",
decimals = 6,
iconUrl = null,
isCustom = false,
contractAddress = contractAddress,
)
}
private fun createCoin(): CryptoCurrency.Coin {
val derivationPath = Network.DerivationPath.None
val network = Network(
id = Network.ID(value = networkRawId, derivationPath = derivationPath),
name = networkRawId,
currencySymbol = "ETH",
derivationPath = derivationPath,
isTestnet = false,
standardType = Network.StandardType.Unspecified("UNSPECIFIED"),
hasFiatFeeRate = true,
canHandleTokens = true,
transactionExtrasType = Network.TransactionExtrasType.NONE,
nameResolvingType = Network.NameResolvingType.NONE,
)
return CryptoCurrency.Coin(
id = CryptoCurrency.ID(
prefix = CryptoCurrency.ID.Prefix.COIN_PREFIX,
body = CryptoCurrency.ID.Body.NetworkId(networkRawId),
suffix = CryptoCurrency.ID.Suffix.RawID(networkRawId),
),
network = network,
name = "Ethereum",
symbol = "ETH",
decimals = 18,
iconUrl = null,
isCustom = false,
)
}
}

View file

@ -0,0 +1,103 @@
package com.tangem.domain.yield.supply.promo.usecase
import com.google.common.truth.Truth.assertThat
import com.tangem.domain.models.wallet.UserWalletId
import com.tangem.domain.yield.supply.models.YieldBoostPromo
import com.tangem.domain.yield.supply.models.YieldBoostStatus
import com.tangem.domain.yield.supply.promo.YieldPromoRepository
import io.mockk.coEvery
import io.mockk.mockk
import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.test.runTest
import kotlinx.datetime.Instant
import org.junit.jupiter.api.BeforeEach
import org.junit.jupiter.api.Test
@OptIn(ExperimentalCoroutinesApi::class)
class ShouldShowYieldBoostMainBannerUseCaseTest {
private val repository: YieldPromoRepository = mockk()
private lateinit var useCase: ShouldShowYieldBoostMainBannerUseCase
private val userWalletId = UserWalletId("abcdef012345")
private val contractAddress = "0xa0b86991c6218b36c1d19d4a2e9eb0ce3606eb48"
private val networkRawId = "ethereum"
@BeforeEach
fun setUp() {
useCase = ShouldShowYieldBoostMainBannerUseCase(repository = repository)
}
@Test
fun `GIVEN promo repository throws WHEN invoke THEN returns Left`() = runTest {
coEvery { repository.getYieldBoostPromo(userWalletId, false) } throws RuntimeException("net")
val result = useCase(userWalletId)
assertThat(result.isLeft()).isTrue()
}
@Test
fun `GIVEN promo is None WHEN invoke THEN returns Right(false)`() = runTest {
coEvery { repository.getYieldBoostPromo(userWalletId, false) } returns YieldBoostPromo.None
val result = useCase(userWalletId)
assertThat(result.getOrNull()).isFalse()
}
@Test
fun `GIVEN status repository throws WHEN invoke THEN returns Left`() = runTest {
coEvery { repository.getYieldBoostPromo(userWalletId, false) } returns activePromo()
coEvery { repository.getYieldBoostStatus(userWalletId, false) } throws RuntimeException("net")
val result = useCase(userWalletId)
assertThat(result.isLeft()).isTrue()
}
@Test
fun `GIVEN status is Enrolled WHEN invoke THEN returns Right(false)`() = runTest {
coEvery { repository.getYieldBoostPromo(userWalletId, false) } returns activePromo()
coEvery { repository.getYieldBoostStatus(userWalletId, false) } returns enrolledStatus()
val result = useCase(userWalletId)
assertThat(result.getOrNull()).isFalse()
}
@Test
fun `GIVEN promo Active and status NotStarted WHEN invoke THEN returns Right(true)`() = runTest {
coEvery { repository.getYieldBoostPromo(userWalletId, false) } returns activePromo()
coEvery { repository.getYieldBoostStatus(userWalletId, false) } returns YieldBoostStatus.NotStarted
val result = useCase(userWalletId)
assertThat(result.getOrNull()).isTrue()
}
private fun activePromo() = YieldBoostPromo.Active(
tokens = listOf(
YieldBoostPromo.Active.PromoToken(
contractAddress = contractAddress,
tokenSymbol = "USDC",
tokenName = "USD Coin",
networkId = networkRawId,
),
),
timeline = YieldBoostPromo.Active.Timeline(
start = Instant.parse("2026-01-01T00:00:00Z"),
end = Instant.parse("2027-01-01T00:00:00Z"),
),
link = null,
)
private fun enrolledStatus() = YieldBoostStatus.Enrolled(
tokenName = "USD Coin",
networkId = networkRawId,
moduleAddress = "0xmodule",
userAddress = "0xuser",
contractAddress = contractAddress,
qualificationEndDate = Instant.parse("2026-06-01T00:00:00Z"),
)
}