Updated on 2026-08-14

This commit is contained in:
Tangem 2026-06-10 18:28:54 +03:00
commit 6b9fc4a3ce
1058 changed files with 48197 additions and 12718 deletions

View file

@ -57,4 +57,11 @@ data class AccountStatusList(
groupType = groupType,
)
}
}
fun AccountStatusList.hasMultiCurrencyAccount(): Boolean = accountStatuses.any { status ->
when (status) {
is AccountStatus.CryptoPortfolio -> status.tokenList.flattenCurrencies().size > 1
is AccountStatus.Payment -> false
}
}

View file

@ -0,0 +1,96 @@
package com.tangem.domain.account.models
import com.google.common.truth.Truth.assertThat
import com.tangem.domain.models.account.AccountStatus
import com.tangem.domain.models.currency.CryptoCurrencyStatus
import com.tangem.domain.models.tokenlist.TokenList
import io.mockk.every
import io.mockk.mockk
import org.junit.jupiter.api.Test
import org.junit.jupiter.api.TestInstance
@TestInstance(TestInstance.Lifecycle.PER_CLASS)
internal class AccountStatusListExtTest {
@Test
fun `GIVEN no accounts WHEN hasMultiCurrencyAccount THEN returns false`() {
val accountList = createAccountStatusList(accountStatuses = emptyList())
val result = accountList.hasMultiCurrencyAccount()
assertThat(result).isFalse()
}
@Test
fun `GIVEN only Payment accounts WHEN hasMultiCurrencyAccount THEN returns false`() {
val accountList = createAccountStatusList(
accountStatuses = listOf(mockk<AccountStatus.Payment>()),
)
val result = accountList.hasMultiCurrencyAccount()
assertThat(result).isFalse()
}
@Test
fun `GIVEN CryptoPortfolio with single currency WHEN hasMultiCurrencyAccount THEN returns false`() {
val accountList = createAccountStatusList(
accountStatuses = listOf(cryptoPortfolioWithCurrencies(count = 1)),
)
val result = accountList.hasMultiCurrencyAccount()
assertThat(result).isFalse()
}
@Test
fun `GIVEN CryptoPortfolio with no currencies WHEN hasMultiCurrencyAccount THEN returns false`() {
val accountList = createAccountStatusList(
accountStatuses = listOf(cryptoPortfolioWithCurrencies(count = 0)),
)
val result = accountList.hasMultiCurrencyAccount()
assertThat(result).isFalse()
}
@Test
fun `GIVEN CryptoPortfolio with multiple currencies WHEN hasMultiCurrencyAccount THEN returns true`() {
val accountList = createAccountStatusList(
accountStatuses = listOf(cryptoPortfolioWithCurrencies(count = 2)),
)
val result = accountList.hasMultiCurrencyAccount()
assertThat(result).isTrue()
}
@Test
fun `GIVEN mix of single and multi currency portfolios WHEN hasMultiCurrencyAccount THEN returns true`() {
val accountList = createAccountStatusList(
accountStatuses = listOf(
cryptoPortfolioWithCurrencies(count = 1),
cryptoPortfolioWithCurrencies(count = 3),
),
)
val result = accountList.hasMultiCurrencyAccount()
assertThat(result).isTrue()
}
private fun createAccountStatusList(accountStatuses: List<AccountStatus>): AccountStatusList {
return mockk {
every { this@mockk.accountStatuses } returns accountStatuses
}
}
private fun cryptoPortfolioWithCurrencies(count: Int): AccountStatus.CryptoPortfolio {
val tokenList = mockk<TokenList> {
every { flattenCurrencies() } returns List(count) { mockk<CryptoCurrencyStatus>() }
}
return mockk {
every { this@mockk.tokenList } returns tokenList
}
}
}

View file

@ -0,0 +1,13 @@
plugins {
alias(deps.plugins.android.library)
alias(deps.plugins.kotlin.android)
id("configuration")
}
android {
namespace = "com.tangem.domain.appsflyer"
}
dependencies {
implementation(deps.kotlin.coroutines)
}

View file

@ -0,0 +1,5 @@
package com.tangem.domain.appsflyer
enum class AppsFlyerDeeplinkSource {
TangemPayHotWalletOnboarding,
}

View file

@ -0,0 +1,8 @@
package com.tangem.domain.appsflyer.repository
import com.tangem.domain.appsflyer.AppsFlyerDeeplinkSource
interface AppsFlyerRepository {
suspend fun clearDeeplink(source: AppsFlyerDeeplinkSource)
}

View file

@ -0,0 +1,12 @@
package com.tangem.domain.appsflyer.usecase
import com.tangem.domain.appsflyer.AppsFlyerDeeplinkSource
import com.tangem.domain.appsflyer.repository.AppsFlyerRepository
class ClearAppsFlyerDeeplinkUseCase(
private val appsFlyerRepository: AppsFlyerRepository,
) {
suspend operator fun invoke(source: AppsFlyerDeeplinkSource) {
appsFlyerRepository.clearDeeplink(source)
}
}

View file

@ -2,6 +2,7 @@ package com.tangem.domain.card.analytics
import com.tangem.core.analytics.models.AnalyticsEvent
import com.tangem.core.analytics.models.AnalyticsParam
import com.tangem.core.analytics.models.CriticalEvent
import com.tangem.core.analytics.models.getReferralParams
sealed class IntroductionProcess(
@ -9,11 +10,17 @@ sealed class IntroductionProcess(
params: Map<String, String> = emptyMap(),
) : AnalyticsEvent("Introduction Process", event, params) {
class ScreenOpened : IntroductionProcess("Introduction Process Screen Opened")
/**
* Tracks the user opening the Introduction Process screen.
*/
class ScreenOpened : IntroductionProcess("Introduction Process Screen Opened"), CriticalEvent
class ButtonTokensList : IntroductionProcess("Button - Tokens List")
class ButtonBuyCards : IntroductionProcess("Button - Buy Cards")
class ButtonScanCardLegacy : IntroductionProcess("Button - Scan Card")
/**
* Tracks opening the Create Wallet introduction screen.
*/
class CreateWalletIntroScreenOpened(
screenType: ScreenType,
referralId: String?,
@ -23,7 +30,7 @@ sealed class IntroductionProcess(
put(AnalyticsParam.SCREEN_TYPE, screenType.value)
putAll(getReferralParams(referralId))
},
) {
), CriticalEvent {
enum class ScreenType(val value: String) {
Cold("Cold Wallet"),
Hot("Mobile Wallet"),

View file

@ -66,6 +66,7 @@ fun CardDTO.supportedBlockchains(
private fun CardDTO.isBlockchainUnsupported(blockchain: Blockchain): Boolean {
return when (blockchain) {
Blockchain.Quai, Blockchain.QuaiTestnet,
Blockchain.Adi, Blockchain.AdiTestnet,
Blockchain.SeiEvm, Blockchain.SeiEvmTestnet,
-> {
firmwareVersion <= FirmwareVersion.HDWalletAvailable

View file

@ -217,6 +217,8 @@ data object Wallet2CardConfig : CardConfig {
Blockchain.ArbitrumNova -> EllipticCurve.Secp256k1
Blockchain.Plasma -> EllipticCurve.Secp256k1
Blockchain.PlasmaTestnet -> EllipticCurve.Secp256k1
Blockchain.Adi -> EllipticCurve.Secp256k1
Blockchain.AdiTestnet -> EllipticCurve.Secp256k1
Blockchain.SeiEvm -> EllipticCurve.Secp256k1
Blockchain.SeiEvmTestnet -> EllipticCurve.Secp256k1
Blockchain.Monad -> EllipticCurve.Secp256k1

View file

@ -4,6 +4,7 @@ import com.tangem.TangemSdk
import com.tangem.blockchain.common.TransactionSigner
import com.tangem.domain.card.models.TwinKey
import com.tangem.domain.models.scan.ProductType
import com.tangem.domain.models.wallet.UserWalletId
/**
* Repository for managing with CardSDK config
@ -28,8 +29,13 @@ interface CardSdkConfigRepository {
/** Update the card ID display format according to the [productType] of the scanned card */
fun updateCardIdDisplayFormat(productType: ProductType)
/** Get common signer by [cardId] */
fun getCommonSigner(cardId: String?, twinKey: TwinKey?): TransactionSigner
/**
* Get common signer by [cardId].
*
* @param userWalletId ID of the user wallet being signed. Used to persist the updated number of signed hashes
* back into the wallet after a successful signing operation.
*/
fun getCommonSigner(cardId: String?, twinKey: TwinKey?, userWalletId: UserWalletId): TransactionSigner
/** Check if linked terminal is enabled */
fun isLinkedTerminal(): Boolean?

View file

@ -173,6 +173,8 @@ class Wallet2CardConfigTest {
Blockchain.ArbitrumNova to EllipticCurve.Secp256k1,
Blockchain.Plasma to EllipticCurve.Secp256k1,
Blockchain.PlasmaTestnet to EllipticCurve.Secp256k1,
Blockchain.Adi to EllipticCurve.Secp256k1,
Blockchain.AdiTestnet to EllipticCurve.Secp256k1,
Blockchain.SeiEvm to EllipticCurve.Secp256k1,
Blockchain.SeiEvmTestnet to EllipticCurve.Secp256k1,
Blockchain.Monad to EllipticCurve.Secp256k1,

View file

@ -1,27 +0,0 @@
package com.tangem.domain.dynamicaddresses
import arrow.core.Either
import com.tangem.domain.dynamicaddresses.repository.DynamicAddressesRepository
import com.tangem.domain.models.network.Network
import com.tangem.domain.models.wallet.UserWalletId
class DisableDynamicAddressesUseCase(
private val dynamicAddressesRepository: DynamicAddressesRepository,
) {
/**
* Returns true when consolidation is required before disabling (non-base balances exist),
* or false when dynamic addresses were disabled immediately.
*/
suspend operator fun invoke(userWalletId: UserWalletId, network: Network): Either<Throwable, Boolean> =
Either.catch {
val hasNonBaseBalances = dynamicAddressesRepository.hasNonBaseBalances(userWalletId, network)
if (!hasNonBaseBalances) {
dynamicAddressesRepository.disable(userWalletId, network)
return@catch false
}
true
}
}

View file

@ -1,5 +1,6 @@
package com.tangem.domain.dynamicaddresses
import com.tangem.crypto.hdWallet.DerivationNode
import com.tangem.crypto.hdWallet.DerivationPath
/**
@ -11,16 +12,24 @@ import com.tangem.crypto.hdWallet.DerivationPath
*/
object DynamicAddressesDerivationChecker {
private const val BIP44_NODE_COUNT = 5
const val BIP44_NODE_COUNT = 5
private const val ACCOUNT_NODE_COUNT = 3
private const val CHANGE_NODE_INDEX = 3
private const val ADDRESS_INDEX_NODE_INDEX = 4
fun parseNodes(path: String): List<DerivationNode>? {
return runCatching { DerivationPath(path).nodes }.getOrNull()
}
/**
* @return `true` if [path] has zero change (node 3) and zero address_index (node 4).
*/
fun isBaseDerivation(path: String): Boolean {
val nodes = runCatching { DerivationPath(path).nodes }.getOrNull() ?: return false
val nodes = parseNodes(path) ?: return false
return isBaseDerivation(nodes)
}
fun isBaseDerivation(nodes: List<DerivationNode>): Boolean {
if (nodes.size < BIP44_NODE_COUNT) return false
val change = nodes[CHANGE_NODE_INDEX].getIndex(includeHardened = false)

View file

@ -4,17 +4,11 @@ import com.tangem.blockchain.common.Blockchain
import com.tangem.blockchainsdk.utils.toNetworkId
/**
* List of blockchains that support Dynamic Addresses (XPUB-based multi-address mode).
* Must match [Blockchain.isBip44DerivationStyleXPUB] from blockchain-sdk minus Kaspa (deferred).
*
* Dynamic addresses are NOT used for Legacy (m/44' for BTC/LTC) or Taproot (m/86') addresses.
* Only the default derivation style per blockchain is supported.
* Whitelist of blockchains eligible for Dynamic Addresses (XPUB-based multi-address mode).
* Mirrors [Blockchain.isBip44DerivationStyleXPUB] from blockchain-sdk minus Kaspa (deferred).
*/
object DynamicAddressesSupportedBlockchains {
private const val BIP44_PURPOSE = 44L
private const val BIP84_PURPOSE = 84L
private val supported = setOf(
Blockchain.Bitcoin,
Blockchain.BitcoinTestnet,
@ -29,26 +23,7 @@ object DynamicAddressesSupportedBlockchains {
private val supportedNetworkIds = supported.map { it.toNetworkId() }.toSet()
/**
* Allowed BIP purpose nodes per network ID.
* BTC/LTC use BIP-84 (SegWit), others use BIP-44 (Legacy P2PKH).
*/
private val allowedPurposeByNetworkId: Map<String, Long> = buildMap {
put(Blockchain.Bitcoin.toNetworkId(), BIP84_PURPOSE)
put(Blockchain.BitcoinTestnet.toNetworkId(), BIP84_PURPOSE)
put(Blockchain.Litecoin.toNetworkId(), BIP84_PURPOSE)
put(Blockchain.BitcoinCash.toNetworkId(), BIP44_PURPOSE)
put(Blockchain.BitcoinCashTestnet.toNetworkId(), BIP44_PURPOSE)
put(Blockchain.Dogecoin.toNetworkId(), BIP44_PURPOSE)
put(Blockchain.Dash.toNetworkId(), BIP44_PURPOSE)
put(Blockchain.Ravencoin.toNetworkId(), BIP44_PURPOSE)
put(Blockchain.RavencoinTestnet.toNetworkId(), BIP44_PURPOSE)
}
fun isSupported(blockchain: Blockchain): Boolean = blockchain in supported
fun isSupportedByNetworkId(networkId: String): Boolean = networkId in supportedNetworkIds
/** Returns the allowed BIP purpose node for the given network, or null if not supported */
fun getAllowedPurpose(networkId: String): Long? = allowedPurposeByNetworkId[networkId]
}

View file

@ -0,0 +1,47 @@
package com.tangem.domain.dynamicaddresses
import com.tangem.blockchainsdk.utils.toBlockchain
import com.tangem.domain.dynamicaddresses.DynamicAddressesDerivationChecker.BIP44_NODE_COUNT
import com.tangem.domain.models.currency.CryptoCurrency
import com.tangem.domain.models.network.Network
import com.tangem.domain.models.wallet.UserWallet
import com.tangem.domain.wallets.derivations.derivationStyleProvider
/**
* Whether the Dynamic Addresses menu entry should be shown for a given (wallet, currency) pair.
* Policy check only hardware XPUB capability is verified by [IsXpubSupportedUseCase].
*/
class IsDynamicAddressesAvailableUseCase(
private val featureToggles: DynamicAddressesFeatureToggles,
) {
operator fun invoke(userWallet: UserWallet, cryptoCurrency: CryptoCurrency): Boolean {
if (!featureToggles.isDynamicAddressesEnabled) return false
if (cryptoCurrency !is CryptoCurrency.Coin) return false
val network = cryptoCurrency.network
if (!DynamicAddressesSupportedBlockchains.isSupportedByNetworkId(network.rawId)) return false
return isWalletDefaultDerivation(userWallet, network)
}
private fun isWalletDefaultDerivation(userWallet: UserWallet, network: Network): Boolean {
val actualPath = network.derivationPath.value ?: return false
val actualNodes = DynamicAddressesDerivationChecker.parseNodes(actualPath) ?: return false
if (!DynamicAddressesDerivationChecker.isBaseDerivation(actualNodes)) return false
val style = userWallet.derivationStyleProvider.getDerivationStyle() ?: return false
val expectedPath = network.toBlockchain().derivationPath(style)?.rawPath ?: return false
val expectedNodes = DynamicAddressesDerivationChecker.parseNodes(expectedPath) ?: return false
if (expectedNodes.size < BIP44_NODE_COUNT) return false
// Match purpose + coin_type; account is allowed to differ for secondary accounts.
return actualNodes[PURPOSE_NODE_INDEX] == expectedNodes[PURPOSE_NODE_INDEX] &&
actualNodes[COIN_TYPE_NODE_INDEX] == expectedNodes[COIN_TYPE_NODE_INDEX]
}
private companion object {
const val PURPOSE_NODE_INDEX = 0
const val COIN_TYPE_NODE_INDEX = 1
}
}

View file

@ -0,0 +1,20 @@
package com.tangem.domain.dynamicaddresses
import arrow.core.Either
import com.tangem.domain.dynamicaddresses.repository.DynamicAddressesRepository
import com.tangem.domain.models.network.Network
import com.tangem.domain.models.wallet.UserWalletId
/**
* Returns `true` if a consolidation transaction must be broadcast before
* [DynamicAddressesRepository.disable] is called (non-base balances exist).
*/
class IsDynamicAddressesConsolidationRequiredUseCase(
private val dynamicAddressesRepository: DynamicAddressesRepository,
) {
suspend operator fun invoke(userWalletId: UserWalletId, network: Network): Either<Throwable, Boolean> =
Either.catch {
dynamicAddressesRepository.hasNonBaseBalances(userWalletId, network)
}
}

View file

@ -0,0 +1,222 @@
package com.tangem.domain.dynamicaddresses
import com.google.common.truth.Truth.assertThat
import com.tangem.blockchain.common.Blockchain
import com.tangem.blockchain.common.derivation.DerivationStyle
import com.tangem.blockchainsdk.utils.toNetworkId
import com.tangem.domain.models.currency.CryptoCurrency
import com.tangem.domain.models.network.Network
import com.tangem.domain.models.wallet.UserWallet
import com.tangem.domain.wallets.derivations.DerivationStyleProvider
import com.tangem.domain.wallets.derivations.derivationStyleProvider
import io.mockk.every
import io.mockk.mockk
import io.mockk.mockkStatic
import io.mockk.unmockkAll
import org.junit.jupiter.api.AfterAll
import org.junit.jupiter.api.BeforeAll
import org.junit.jupiter.api.Test
import org.junit.jupiter.api.TestInstance
@TestInstance(TestInstance.Lifecycle.PER_CLASS)
class IsDynamicAddressesAvailableUseCaseTest {
private val featureToggles: DynamicAddressesFeatureToggles = mockk()
private val useCase = IsDynamicAddressesAvailableUseCase(featureToggles)
@BeforeAll
fun setup() {
mockkStatic("com.tangem.domain.wallets.derivations.DerivationStyleProviderExtKt")
every { featureToggles.isDynamicAddressesEnabled } returns true
}
@AfterAll
fun teardown() {
unmockkAll()
}
// region Gating
@Test
fun `feature toggle off returns false`() {
every { featureToggles.isDynamicAddressesEnabled } returns false
val coin = coin(Blockchain.Bitcoin, "m/84'/0'/0'/0/0")
val result = useCase(walletWithStyle(DerivationStyle.V3), coin)
assertThat(result).isFalse()
every { featureToggles.isDynamicAddressesEnabled } returns true // restore
}
@Test
fun `token currency returns false`() {
val token = token(Blockchain.Ethereum, "m/44'/60'/0'/0/0")
val result = useCase(walletWithStyle(DerivationStyle.V3), token)
assertThat(result).isFalse()
}
@Test
fun `unsupported network returns false`() {
val coin = coin(Blockchain.Ethereum, "m/44'/60'/0'/0/0")
val result = useCase(walletWithStyle(DerivationStyle.V3), coin)
assertThat(result).isFalse()
}
@Test
fun `non-HD wallet returns false`() {
val coin = coin(Blockchain.Bitcoin, "m/84'/0'/0'/0/0")
val result = useCase(walletWithStyle(style = null), coin)
assertThat(result).isFalse()
}
// endregion
// region BTC: V2 (Wallet 1) ↔ V3 (Wallet 2 / Hot)
@Test
fun `V3 wallet accepts BIP-84 BTC`() {
val coin = coin(Blockchain.Bitcoin, "m/84'/0'/0'/0/0")
val result = useCase(walletWithStyle(DerivationStyle.V3), coin)
assertThat(result).isTrue()
}
@Test
fun `V3 wallet rejects BIP-44 BTC`() {
val coin = coin(Blockchain.Bitcoin, "m/44'/0'/0'/0/0")
val result = useCase(walletWithStyle(DerivationStyle.V3), coin)
assertThat(result).isFalse()
}
@Test
fun `V2 wallet accepts BIP-44 BTC`() {
val coin = coin(Blockchain.Bitcoin, "m/44'/0'/0'/0/0")
val result = useCase(walletWithStyle(DerivationStyle.V2), coin)
assertThat(result).isTrue()
}
@Test
fun `V2 wallet rejects BIP-84 BTC`() {
val coin = coin(Blockchain.Bitcoin, "m/84'/0'/0'/0/0")
val result = useCase(walletWithStyle(DerivationStyle.V2), coin)
assertThat(result).isFalse()
}
// endregion
// region LTC: same dual-style behavior
@Test
fun `V3 wallet accepts BIP-84 LTC`() {
val coin = coin(Blockchain.Litecoin, "m/84'/2'/0'/0/0")
val result = useCase(walletWithStyle(DerivationStyle.V3), coin)
assertThat(result).isTrue()
}
@Test
fun `V2 wallet accepts BIP-44 LTC`() {
val coin = coin(Blockchain.Litecoin, "m/44'/2'/0'/0/0")
val result = useCase(walletWithStyle(DerivationStyle.V2), coin)
assertThat(result).isTrue()
}
@Test
fun `coin_type mismatch is rejected`() {
// BTC coin_type is 0; using LTC's coin_type 2 must fail
val coin = coin(Blockchain.Bitcoin, "m/84'/2'/0'/0/0")
val result = useCase(walletWithStyle(DerivationStyle.V3), coin)
assertThat(result).isFalse()
}
// endregion
// region Other supported chains (V2 and V3 share BIP-44)
@Test
fun `V3 wallet accepts BIP-44 Dogecoin`() {
val coin = coin(Blockchain.Dogecoin, "m/44'/3'/0'/0/0")
val result = useCase(walletWithStyle(DerivationStyle.V3), coin)
assertThat(result).isTrue()
}
@Test
fun `V2 wallet accepts BIP-44 Dash`() {
val coin = coin(Blockchain.Dash, "m/44'/5'/0'/0/0")
val result = useCase(walletWithStyle(DerivationStyle.V2), coin)
assertThat(result).isTrue()
}
// endregion
// region Account & non-base path
@Test
fun `secondary account is accepted`() {
val coin = coin(Blockchain.Bitcoin, "m/84'/0'/3'/0/0")
val result = useCase(walletWithStyle(DerivationStyle.V3), coin)
assertThat(result).isTrue()
}
@Test
fun `non-zero change is rejected`() {
val coin = coin(Blockchain.Bitcoin, "m/84'/0'/0'/1/0")
val result = useCase(walletWithStyle(DerivationStyle.V3), coin)
assertThat(result).isFalse()
}
@Test
fun `non-zero address index is rejected`() {
val coin = coin(Blockchain.Bitcoin, "m/84'/0'/0'/0/5")
val result = useCase(walletWithStyle(DerivationStyle.V3), coin)
assertThat(result).isFalse()
}
@Test
fun `path with fewer than 5 nodes is rejected`() {
val coin = coin(Blockchain.Bitcoin, "m/84'/0'/0'")
val result = useCase(walletWithStyle(DerivationStyle.V3), coin)
assertThat(result).isFalse()
}
// endregion
// region helpers
private fun walletWithStyle(style: DerivationStyle?): UserWallet {
val wallet: UserWallet = mockk()
val provider = object : DerivationStyleProvider {
override fun getDerivationStyle(): DerivationStyle? = style
}
every { wallet.derivationStyleProvider } returns provider
return wallet
}
private fun network(blockchain: Blockchain, derivationPathValue: String): Network {
val derivationPath = Network.DerivationPath.Card(derivationPathValue)
return Network(
id = Network.ID(value = blockchain.toNetworkId(), derivationPath = derivationPath),
name = blockchain.fullName,
currencySymbol = blockchain.currency,
derivationPath = derivationPath,
isTestnet = blockchain.isTestnet(),
standardType = Network.StandardType.Unspecified(blockchain.fullName),
hasFiatFeeRate = true,
canHandleTokens = false,
transactionExtrasType = Network.TransactionExtrasType.NONE,
nameResolvingType = Network.NameResolvingType.NONE,
)
}
private fun coin(blockchain: Blockchain, derivationPathValue: String): CryptoCurrency.Coin {
val coin: CryptoCurrency.Coin = mockk()
every { coin.network } returns network(blockchain, derivationPathValue)
return coin
}
private fun token(blockchain: Blockchain, derivationPathValue: String): CryptoCurrency.Token {
val token: CryptoCurrency.Token = mockk()
every { token.network } returns network(blockchain, derivationPathValue)
return token
}
// endregion
}

View file

@ -0,0 +1,3 @@
package com.tangem.domain.express.models
enum class ProviderFilterType { ALL, CEX, DEX }

View file

@ -24,7 +24,7 @@ dependencies {
api(projects.domain.walletManager)
api(projects.domain.wallets)
api(projects.domain.wallets.models)
api(projects.domain.promo)
api(projects.domain.stories)
implementation(projects.domain.tokens.models)
implementation(projects.domain.tokens)

View file

@ -31,8 +31,14 @@ sealed class PaymentAccountStatusValue {
is UnderReview,
-> TotalFiatBalance.Loaded(amount = SerializedBigDecimal.ZERO, source = source)
is Loading -> TotalFiatBalance.Loading
is Loaded -> TotalFiatBalance.Loaded(amount = fiatBalance.availableBalance, source = source)
is Deactivated -> TotalFiatBalance.Loaded(amount = fiatBalance.availableBalance, source = source)
is Loaded -> {
val rate = this.fiatRate ?: return TotalFiatBalance.Failed
TotalFiatBalance.Loaded(amount = fiatBalance.availableBalance.multiply(rate), source = source)
}
is Deactivated -> {
val rate = this.fiatRate ?: return TotalFiatBalance.Failed
TotalFiatBalance.Loaded(amount = fiatBalance.availableBalance.multiply(rate), source = source)
}
}
/**
@ -99,12 +105,30 @@ sealed class PaymentAccountStatusValue {
*
* @property source The source of the status information.
* @property fiatBalance The fiat balance details.
* @property cryptoBalance The crypto balance details.
* @property cryptoCurrency The crypto currency held by the deactivated account.
* @property fiatRate Exchange rate of [cryptoCurrency] to the account's fiat currency,
* or `null` if the quote is not yet available. When `null`,
* [totalFiatBalance] resolves to [TotalFiatBalance.Failed].
*/
@Serializable
data class Deactivated(
override val source: StatusSource,
val fiatBalance: FiatBalance,
) : PaymentAccountStatusValue()
val cryptoBalance: CryptoBalance,
val cryptoCurrency: CryptoCurrency.Token,
val fiatRate: SerializedBigDecimal?,
) : PaymentAccountStatusValue() {
val cryptoCurrencyStatus: CryptoCurrencyStatus = CryptoCurrencyStatus(
currency = cryptoCurrency,
value = buildCryptoCurrencyStatusValue(
amount = cryptoBalance.balance,
fiatAmount = fiatBalance.availableBalance,
fiatRate = fiatRate,
depositAddress = cryptoBalance.depositAddress,
),
)
}
/**
* Represents a state where the payment account is successfully loaded with complete information.
@ -116,7 +140,11 @@ sealed class PaymentAccountStatusValue {
* @property fiatBalance The fiat balance details.
* @property cryptoBalance The crypto balance details.
* @property availableForWithdrawal The crypto amount currently available for withdrawal/swap (excludes pending/locked funds).
* @property cryptoCurrency The crypto currency held by the account.
* @property cards The list of user's cards.
* @property fiatRate Exchange rate of [cryptoCurrency] to the account's fiat currency,
* or `null` if the quote is not yet available. When `null`,
* [totalFiatBalance] resolves to [TotalFiatBalance.Failed].
*/
@Serializable
data class Loaded(
@ -129,25 +157,15 @@ sealed class PaymentAccountStatusValue {
val availableForWithdrawal: SerializedBigDecimal,
val cryptoCurrency: CryptoCurrency.Token,
val cards: List<TangemPayCard>,
val fiatRate: SerializedBigDecimal?,
) : PaymentAccountStatusValue() {
val cryptoCurrencyStatus: CryptoCurrencyStatus = CryptoCurrencyStatus(
currency = cryptoCurrency,
value = CryptoCurrencyStatus.Loaded(
value = buildCryptoCurrencyStatusValue(
amount = availableForWithdrawal,
fiatAmount = fiatBalance.availableBalance,
fiatRate = BigDecimal.ONE,
priceChange = BigDecimal.ZERO,
networkAddress = NetworkAddress.Single(
defaultAddress = NetworkAddress.Address(
type = NetworkAddress.Address.Type.Primary,
value = cryptoBalance.depositAddress,
),
),
sources = CryptoCurrencyStatus.Sources(),
pendingTransactions = emptySet(),
stakingBalance = null,
yieldSupplyStatus = null,
hasCurrentNetworkTransactions = false,
fiatRate = fiatRate,
depositAddress = cryptoBalance.depositAddress,
),
)
}
@ -212,6 +230,44 @@ sealed class PaymentAccountStatusValue {
)
}
private fun buildCryptoCurrencyStatusValue(
amount: SerializedBigDecimal,
fiatAmount: SerializedBigDecimal,
fiatRate: SerializedBigDecimal?,
depositAddress: String,
): CryptoCurrencyStatus.Value {
val networkAddress = NetworkAddress.Single(
defaultAddress = NetworkAddress.Address(
type = NetworkAddress.Address.Type.Primary,
value = depositAddress,
),
)
return if (fiatRate != null) {
CryptoCurrencyStatus.Loaded(
amount = amount,
fiatAmount = fiatAmount,
fiatRate = fiatRate,
priceChange = BigDecimal.ZERO,
networkAddress = networkAddress,
sources = CryptoCurrencyStatus.Sources(),
pendingTransactions = emptySet(),
stakingBalance = null,
yieldSupplyStatus = null,
hasCurrentNetworkTransactions = false,
)
} else {
CryptoCurrencyStatus.NoQuote(
amount = amount,
networkAddress = networkAddress,
stakingBalance = null,
yieldSupplyStatus = null,
hasCurrentNetworkTransactions = false,
pendingTransactions = emptySet(),
sources = CryptoCurrencyStatus.Sources(),
)
}
}
fun Loaded.hasCardWithId(cardId: String): Boolean = cards.any { it.id == cardId }
fun Loaded.findCardWithId(cardId: String): TangemPayCard? = cards.firstOrNull { it.id == cardId }

View file

@ -2,14 +2,20 @@ package com.tangem.domain.models.staking
import java.math.BigDecimal
val P2PEthPoolStakingAccount.unstakingAssets: BigDecimal
get() = exitQueue.requests.filter { !it.isClaimable }.sumOf { it.totalAssets }
val P2PEthPoolStakingAccount.withdrawableAssets: BigDecimal
get() = exitQueue.requests.filter { it.isClaimable }.sumOf { it.totalAssets }
fun P2PEthPoolStakingAccount.toStakingBalanceEntries(vaultName: String? = null): List<StakingBalanceEntry> {
return buildList {
if (stake.assets > BigDecimal.ZERO) {
add(createStakedEntry(vaultAddress, stake.assets, vaultName))
}
exitQueue.requests.filter { !it.isClaimable }.forEach { add(createUnstakingEntry(vaultAddress, it, vaultName)) }
if (availableToWithdraw > BigDecimal.ZERO) {
add(createWithdrawableEntry(vaultAddress, availableToWithdraw, vaultName))
if (withdrawableAssets > BigDecimal.ZERO) {
add(createWithdrawableEntry(vaultAddress, withdrawableAssets, vaultName))
}
if (stake.totalEarnedAssets > BigDecimal.ZERO) {
add(createRewardsEntry(vaultAddress, stake.totalEarnedAssets, vaultName))

View file

@ -62,9 +62,9 @@ sealed interface StakingBalance {
override val totalRewards: SerializedBigDecimal = accounts.sumOf { it.stake.totalEarnedAssets }
override val unstakingAmount: SerializedBigDecimal = accounts.sumOf { it.exitQueue.total }
override val unstakingAmount: SerializedBigDecimal = accounts.sumOf { it.unstakingAssets }
override val withdrawableAmount: SerializedBigDecimal = accounts.sumOf { it.availableToWithdraw }
override val withdrawableAmount: SerializedBigDecimal = accounts.sumOf { it.withdrawableAssets }
override val entries: List<StakingBalanceEntry> = accounts.flatMap { it.toStakingBalanceEntries() }
}

View file

@ -93,6 +93,13 @@ fun UserWallet.isImported(): Boolean {
}
}
fun UserWallet.isBackedUpForAnalytics(): Boolean {
return when (this) {
is UserWallet.Cold -> scanResponse.card.backupStatus?.isActive == true
is UserWallet.Hot -> backedUp
}
}
fun UserWallet.copy(name: String = this.name, walletId: UserWalletId = this.walletId): UserWallet = when (this) {
is UserWallet.Cold -> this.copy(name = name, walletId = walletId)
is UserWallet.Hot -> this.copy(name = name, walletId = walletId)

View file

@ -19,7 +19,7 @@ dependencies {
api(projects.domain.core)
api(projects.domain.settings)
implementation(deps.kotlin.serialization)
implementation(projects.domain.promo)
implementation(projects.domain.stories)
/** Tests */
testImplementation(deps.test.coroutine)

View file

@ -6,5 +6,4 @@ enum class OnrampSource(val analyticsName: String) {
TOKEN_LONG_TAP("Long Tap"),
TOKEN_DETAILS("Token"),
MARKETS("Markets"),
SEPA_BANNER("SEPA Banner"),
}

View file

@ -11,11 +11,9 @@ import com.tangem.domain.onramp.repositories.OnrampRepository
import com.tangem.domain.onramp.repositories.OnrampTransactionRepository
import com.tangem.domain.onramp.utils.calculateRateDif
import com.tangem.domain.onramp.utils.compareOffersByRateSpeedAndPriority
import com.tangem.domain.promo.PromoRepository
import com.tangem.domain.settings.repositories.SettingsRepository
import kotlinx.coroutines.flow.catch
import kotlinx.coroutines.flow.combine
import kotlinx.coroutines.flow.flow
import kotlinx.coroutines.flow.map
class GetOnrampOffersUseCase(
@ -23,16 +21,14 @@ class GetOnrampOffersUseCase(
private val onrampTransactionRepository: OnrampTransactionRepository,
private val errorResolver: OnrampErrorResolver,
private val settingsRepository: SettingsRepository,
private val promoRepository: PromoRepository,
) {
operator fun invoke(): EitherFlow<OnrampError, List<OnrampOffersBlock>> {
return combine(
onrampRepository.getQuotes(),
onrampTransactionRepository.getAllTransactions(),
flow { emit(promoRepository.isMoonpayPromoActive()) },
) { quotes, transactions, isMoonpayPromoActive ->
processOffers(quotes, transactions, isMoonpayPromoActive)
) { quotes, transactions ->
processOffers(quotes, transactions)
}
.map { offers -> offers.right() }
.catch { throwable -> errorResolver.resolve(throwable).left() }
@ -41,7 +37,6 @@ class GetOnrampOffersUseCase(
private suspend fun processOffers(
quotes: List<OnrampQuote>,
transactions: List<OnrampTransaction>,
isMoonpayPromoActive: Boolean,
): List<OnrampOffersBlock> {
val validQuotes = quotes.filterIsInstance<OnrampQuote.Data>()
if (validQuotes.isEmpty()) return emptyList()
@ -62,7 +57,7 @@ class GetOnrampOffersUseCase(
val recentOffer = findRecentOffer(offers, transactions)
val bestRateOffer = findBestRateOffer(offers, isGooglePayAvailable)
val fastestOffer = findFastestOffer(offers, isGooglePayAvailable, isMoonpayPromoActive)
val fastestOffer = findFastestOffer(offers, isGooglePayAvailable)
return buildOffersBlocks(
recentOffer = recentOffer,
@ -90,23 +85,8 @@ class GetOnrampOffersUseCase(
return offers.maxWithOrNull(offerComparator(isGooglePayAvailable))
}
private fun findFastestOffer(
offers: List<OnrampOffer>,
isGooglePayAvailable: Boolean,
isMoonpayPromoActive: Boolean,
): OnrampOffer? {
val moonpayPromoOffers = if (isMoonpayPromoActive) {
offers.filter { offer ->
offer.quote.provider.id == MOONPAY_PROMO_PROVIDER_ID &&
offer.quote.paymentMethod.type == PaymentMethodType.GOOGLE_PAY
}
} else {
emptyList()
}
val instantOffers = moonpayPromoOffers.ifEmpty {
offers.filter { it.quote.paymentMethod.type.isInstant() }
}
private fun findFastestOffer(offers: List<OnrampOffer>, isGooglePayAvailable: Boolean): OnrampOffer? {
val instantOffers = offers.filter { it.quote.paymentMethod.type.isInstant() }
return if (instantOffers.isNotEmpty()) {
instantOffers.maxWithOrNull(fastestOfferComparator(isGooglePayAvailable))
@ -308,8 +288,4 @@ class GetOnrampOffersUseCase(
-> true
}
}
private companion object {
const val MOONPAY_PROMO_PROVIDER_ID = "moonpay"
}
}

View file

@ -1,77 +0,0 @@
package com.tangem.domain.onramp
import arrow.core.Either
import arrow.core.getOrElse
import com.tangem.domain.models.currency.CryptoCurrency
import com.tangem.domain.onramp.repositories.OnrampRepository
import com.tangem.domain.models.wallet.UserWallet
import com.tangem.domain.onramp.model.OnrampCountry
class OnrampSepaAvailableUseCase(
private val repository: OnrampRepository,
) {
suspend operator fun invoke(
userWallet: UserWallet,
country: OnrampCountry,
cryptoCurrency: CryptoCurrency,
): Boolean {
if (country.code !in SEPA_AVAILABLE_COUNTRY_CODES) {
return false
}
return Either.catch {
repository.hasSepaMethod(
userWallet = userWallet,
country = country,
cryptoCurrency = cryptoCurrency,
)
}.getOrElse { false }
}
companion object {
val SEPA_AVAILABLE_COUNTRY_CODES = listOf(
"AL", // Albania
"AD", // Andorra
"AT", // Austria
"BE", // Belgium
"BG", // Bulgaria
"HR", // Croatia
"CY", // Cyprus
"CZ", // Czech Republic
"DK", // Denmark
"EE", // Estonia
"FI", // Finland
"FR", // France
"DE", // Germany
"GR", // Greece
"HU", // Hungary
"IS", // Iceland
"IE", // Ireland
"IT", // Italy
"LV", // Latvia
"LI", // Liechtenstein
"LT", // Lithuania
"LU", // Luxembourg
"MT", // Malta
"MD", // Moldova
"MC", // Monaco
"ME", // Montenegro
"NL", // Netherlands
"MK", // North Macedonia
"NO", // Norway
"PL", // Poland
"PT", // Portugal
"RO", // Romania
"SM", // San Marino
"RS", // Serbia
"SK", // Slovakia
"SI", // Slovenia
"ES", // Spain
"SE", // Sweden
"CH", // Switzerland
"GB", // United Kingdom
"VA", // Vatican City
)
}
}

View file

@ -15,7 +15,6 @@ interface OnrampRepository {
suspend fun getCountriesSync(): List<OnrampCountry>?
suspend fun getCountryByIp(userWallet: UserWallet, fromCache: Boolean = false): OnrampCountry
suspend fun getStatus(userWallet: UserWallet, txId: String): OnrampStatus
suspend fun hasSepaMethod(userWallet: UserWallet, country: OnrampCountry, cryptoCurrency: CryptoCurrency): Boolean
suspend fun fetchCurrencies(userWallet: UserWallet)
suspend fun fetchCountries(userWallet: UserWallet): List<OnrampCountry>
suspend fun fetchPaymentMethodsIfAbsent(userWallet: UserWallet)

View file

@ -7,7 +7,6 @@ import com.tangem.domain.onramp.model.cache.OnrampTransaction
import com.tangem.domain.onramp.repositories.OnrampErrorResolver
import com.tangem.domain.onramp.repositories.OnrampRepository
import com.tangem.domain.onramp.repositories.OnrampTransactionRepository
import com.tangem.domain.promo.PromoRepository
import com.tangem.domain.settings.repositories.SettingsRepository
import io.mockk.*
import kotlinx.coroutines.flow.flowOf
@ -25,7 +24,6 @@ class GetOnrampOffersUseCaseTest {
private val errorResolver: OnrampErrorResolver = mockk(relaxUnitFun = true)
private val settingsRepository: SettingsRepository = mockk(relaxUnitFun = true)
private val cryptoCurrencyId: CryptoCurrency.ID = mockk(relaxUnitFun = true)
private val promoRepository: PromoRepository = mockk(relaxUnitFun = true)
private lateinit var useCase: GetOnrampOffersUseCase
@ -37,7 +35,6 @@ class GetOnrampOffersUseCaseTest {
onrampTransactionRepository = onrampTransactionRepository,
errorResolver = errorResolver,
settingsRepository = settingsRepository,
promoRepository = promoRepository,
)
}
@ -228,7 +225,6 @@ class GetOnrampOffersUseCaseTest {
val transactions = emptyList<OnrampTransaction>()
coEvery { promoRepository.isMoonpayPromoActive() } returns false
coEvery { settingsRepository.isGooglePayAvailability() } returns false
coEvery { onrampRepository.getQuotes() } returns flowOf(quotes)
coEvery { onrampTransactionRepository.getAllTransactions() } returns flowOf(
@ -249,107 +245,6 @@ class GetOnrampOffersUseCaseTest {
}
}
@Test
fun `invoke should fallback to standard instant offers when promo is active but no Moonpay offers exist`() =
runTest {
val instantMethod = createMockPaymentMethod("gpay", "Google Pay", PaymentMethodType.GOOGLE_PAY)
val slowMethod = createMockPaymentMethod("bank", "Bank Transfer", PaymentMethodType.CARD)
val provider = createMockProvider("other", "Other Provider")
val quotes = listOf(
createMockQuote(instantMethod, provider, BigDecimal("95.0")),
createMockQuote(slowMethod, provider, BigDecimal("100.0")),
)
val transactions = emptyList<OnrampTransaction>()
coEvery { promoRepository.isMoonpayPromoActive() } returns true
coEvery { settingsRepository.isGooglePayAvailability() } returns true
coEvery { onrampRepository.getQuotes() } returns flowOf(quotes)
coEvery { onrampTransactionRepository.getAllTransactions() } returns flowOf(transactions)
val result = useCase()
result.collect { either ->
Truth.assertThat(either.isRight()).isTrue()
either.fold(
ifLeft = { error -> Truth.assertThat(error).isNull() },
ifRight = { offers ->
Truth.assertThat(offers).hasSize(1)
val recommendedBlock = offers.find { it.category == OnrampOfferCategory.Recommended }
Truth.assertThat(recommendedBlock).isNotNull()
Truth.assertThat(recommendedBlock?.offers).hasSize(2)
val fastestOffer =
recommendedBlock?.offers?.find { it.advantages == OnrampOfferAdvantages.Fastest }
Truth.assertThat(fastestOffer).isNotNull()
when (val quote = fastestOffer?.quote) {
is OnrampQuote.Data -> {
Truth.assertThat(quote.provider.id).isNotEqualTo("moonpay")
Truth.assertThat(quote.paymentMethod.type).isEqualTo(PaymentMethodType.GOOGLE_PAY)
}
else -> Truth.assertThat(false).isTrue()
}
},
)
}
}
@Test
fun `invoke should show Moonpay fastest offer when promo is active`() = runTest {
val moonpayGooglePayMethod = createMockPaymentMethod("moonpay-gpay", "Google Pay", PaymentMethodType.GOOGLE_PAY)
val otherGooglePayMethod = createMockPaymentMethod(
"other-gpay",
"Other Google Pay",
PaymentMethodType.GOOGLE_PAY,
)
val slowMethod = createMockPaymentMethod("bank", "Bank Transfer", PaymentMethodType.CARD)
val moonpayProvider = createMockProvider("moonpay", "Moonpay")
val otherProvider = createMockProvider("other", "Other Provider")
val quotes = listOf(
createMockQuote(moonpayGooglePayMethod, moonpayProvider, BigDecimal("100.0")),
createMockQuote(otherGooglePayMethod, otherProvider, BigDecimal("95.0")),
createMockQuote(slowMethod, otherProvider, BigDecimal("105.0")),
)
val transactions = emptyList<OnrampTransaction>()
coEvery { promoRepository.isMoonpayPromoActive() } returns true
coEvery { settingsRepository.isGooglePayAvailability() } returns true
coEvery { onrampRepository.getQuotes() } returns flowOf(quotes)
coEvery { onrampTransactionRepository.getAllTransactions() } returns flowOf(transactions)
val result = useCase()
result.collect { either ->
Truth.assertThat(either.isRight()).isTrue()
either.fold(
ifLeft = { error -> Truth.assertThat(error).isNull() },
ifRight = { offers ->
Truth.assertThat(offers).hasSize(1)
val recommendedBlock = offers.find { it.category == OnrampOfferCategory.Recommended }
Truth.assertThat(recommendedBlock).isNotNull()
val fastestOffer = recommendedBlock?.offers?.find { it.advantages == OnrampOfferAdvantages.Fastest }
Truth.assertThat(fastestOffer).isNotNull()
when (val quote = fastestOffer?.quote) {
is OnrampQuote.Data -> {
Truth.assertThat(quote.provider.id).isEqualTo("moonpay")
Truth.assertThat(quote.paymentMethod.type).isEqualTo(PaymentMethodType.GOOGLE_PAY)
Truth.assertThat(quote.toAmount.value).isEqualTo(BigDecimal("100.0"))
}
else -> Truth.assertThat(false).isTrue()
}
},
)
}
}
private fun createMockPaymentMethod(
id: String,
name: String,

View file

@ -1,35 +0,0 @@
package com.tangem.domain.promo.models
import org.joda.time.DateTime
data class PromoBanner(
val name: String,
val bannerState: BannerState,
) {
val isActive = bannerState.status == ACTIVE_STATUS && bannerState.timeline.end.isAfterNow
data class BannerState(
val timeline: Timeline,
val status: String,
val link: String?,
)
data class Timeline(
val start: DateTime,
val end: DateTime,
)
private companion object {
const val ACTIVE_STATUS = "active"
}
}
enum class PromoId {
Referral,
Sepa,
VisaPresale,
BlackFriday,
OnePlusOne,
YieldPromo,
}

View file

@ -1,33 +0,0 @@
package com.tangem.domain.promo
import com.tangem.domain.models.wallet.UserWalletId
import com.tangem.domain.promo.models.PromoId
import com.tangem.domain.promo.models.StoryContent
import kotlinx.coroutines.flow.Flow
interface PromoRepository {
// region Promo
fun isReadyToShowWalletPromo(userWalletId: UserWalletId, promoId: PromoId): Flow<Boolean>
fun isReadyToShowTokenPromo(promoId: PromoId): Flow<Boolean>
suspend fun setNeverToShowWalletPromo(promoId: PromoId)
suspend fun setNeverToShowTokenPromo(promoId: PromoId)
suspend fun isMoonpayPromoActive(): Boolean
// endregion
// region Stories
fun getStoryById(id: String): Flow<StoryContent?>
suspend fun getStoryByIdSync(id: String, refresh: Boolean): StoryContent?
fun isReadyToShowStories(storyId: String): Flow<Boolean>
suspend fun isReadyToShowStoriesSync(storyId: String): Boolean
suspend fun setNeverToShowStories(storyId: String)
// endregion
}

View file

@ -1,11 +0,0 @@
package com.tangem.domain.promo
import com.tangem.domain.promo.models.PromoId
import kotlinx.coroutines.flow.Flow
class ShouldShowPromoTokenUseCase(private val promoRepository: PromoRepository) {
operator fun invoke(promoId: PromoId): Flow<Boolean> = promoRepository.isReadyToShowTokenPromo(promoId)
suspend fun neverToShow(promoId: PromoId) = promoRepository.setNeverToShowTokenPromo(promoId)
}

View file

@ -1,57 +0,0 @@
package com.tangem.domain.promo
import com.tangem.domain.models.wallet.UserWalletId
import com.tangem.domain.promo.models.PromoId
import com.tangem.domain.settings.repositories.SettingsRepository
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.emitAll
import kotlinx.coroutines.flow.flow
import kotlinx.coroutines.flow.flowOf
import kotlinx.coroutines.flow.map
import java.util.Calendar
class ShouldShowPromoWalletUseCase(
private val promoRepository: PromoRepository,
private val settingsRepository: SettingsRepository,
private val isNewPromoBannersEnabled: Boolean,
) {
operator fun invoke(userWalletId: UserWalletId, promoId: PromoId): Flow<Boolean> {
if (isNewPromoBannersEnabled) return flowOf(false)
return flow {
emit(false)
val promoFlow = promoRepository.isReadyToShowWalletPromo(userWalletId, promoId)
.map { applyWalletFirstUsageCondition(promoId, it) }
emitAll(promoFlow)
}
}
private suspend fun applyWalletFirstUsageCondition(promoId: PromoId, isReady: Boolean): Boolean {
if (!isReady) return false
return when (promoId) {
PromoId.Referral,
PromoId.VisaPresale,
PromoId.BlackFriday,
PromoId.OnePlusOne,
PromoId.YieldPromo,
-> true
PromoId.Sepa -> {
val walletFirstUsageDate = settingsRepository.getWalletFirstUsageDate()
if (walletFirstUsageDate == 0L) return false
val currentDate = Calendar.getInstance().timeInMillis
currentDate - walletFirstUsageDate > ONE_DAY_IN_MILLIS
}
}
}
suspend fun neverToShow(promoId: PromoId) = promoRepository.setNeverToShowWalletPromo(promoId)
private companion object {
const val ONE_DAY_IN_MILLIS = 1 * 24 * 60 * 60 * 1000L
}
}

View file

@ -1,10 +0,0 @@
package com.tangem.domain.promo
import kotlinx.coroutines.flow.Flow
class ShouldShowStoriesUseCase(private val promoRepository: PromoRepository) {
operator fun invoke(storyId: String): Flow<Boolean> = promoRepository.isReadyToShowStories(storyId)
suspend fun invokeSync(storyId: String): Boolean = promoRepository.isReadyToShowStoriesSync(storyId)
suspend fun neverToShow(storyId: String) = promoRepository.setNeverToShowStories(storyId)
}

View file

@ -0,0 +1,24 @@
plugins {
alias(deps.plugins.android.library)
alias(deps.plugins.kotlin.android)
alias(deps.plugins.kotlin.kapt)
alias(deps.plugins.hilt.android)
id("configuration")
}
android {
namespace = "com.tangem.domain.pushnotificationpreferences"
}
dependencies {
/** Domain */
implementation(projects.domain.models)
/** Other */
implementation(deps.arrow.core)
implementation(deps.kotlin.coroutines)
/** DI */
implementation(deps.hilt.android)
kapt(deps.hilt.kapt)
}

View file

@ -0,0 +1,14 @@
package com.tangem.domain.pushnotificationpreferences
import com.tangem.domain.models.wallet.UserWalletId
import com.tangem.domain.pushnotificationpreferences.models.WalletPushNotificationPreferences
import com.tangem.domain.pushnotificationpreferences.repository.WalletPushNotificationPreferencesRepository
import kotlinx.coroutines.flow.Flow
class ObserveWalletPushNotificationPreferencesUseCase(
private val repository: WalletPushNotificationPreferencesRepository,
) {
operator fun invoke(userWalletId: UserWalletId): Flow<WalletPushNotificationPreferences> =
repository.observePreferences(userWalletId)
}

View file

@ -0,0 +1,11 @@
package com.tangem.domain.pushnotificationpreferences
import com.tangem.domain.models.wallet.UserWalletId
import com.tangem.domain.pushnotificationpreferences.repository.WalletPushNotificationPreferencesRepository
class PreloadWalletPushNotificationPreferencesUseCase(
private val repository: WalletPushNotificationPreferencesRepository,
) {
suspend operator fun invoke(userWalletId: UserWalletId) = repository.preload(userWalletId)
}

View file

@ -0,0 +1,21 @@
package com.tangem.domain.pushnotificationpreferences
import arrow.core.Either
import com.tangem.domain.models.wallet.UserWalletId
import com.tangem.domain.pushnotificationpreferences.models.PushNotificationCategory
import com.tangem.domain.pushnotificationpreferences.repository.WalletPushNotificationPreferencesRepository
class UpdateWalletPushNotificationPreferenceUseCase(
private val repository: WalletPushNotificationPreferencesRepository,
) {
suspend operator fun invoke(
userWalletId: UserWalletId,
category: PushNotificationCategory,
isEnabled: Boolean,
): Either<Throwable, Unit> = repository.updatePreference(
userWalletId = userWalletId,
category = category,
isEnabled = isEnabled,
)
}

View file

@ -0,0 +1,7 @@
package com.tangem.domain.pushnotificationpreferences.models
enum class PushNotificationCategory {
TransactionAlerts,
OffersUpdates,
PriceAlerts,
}

View file

@ -0,0 +1,6 @@
package com.tangem.domain.pushnotificationpreferences.models
data class PushNotificationPreference(
val isEnabled: Boolean,
val isVisible: Boolean,
)

View file

@ -0,0 +1,7 @@
package com.tangem.domain.pushnotificationpreferences.models
data class WalletPushNotificationPreferences(
val transactionAlerts: PushNotificationPreference,
val offersUpdates: PushNotificationPreference,
val priceAlerts: PushNotificationPreference,
)

View file

@ -0,0 +1,23 @@
package com.tangem.domain.pushnotificationpreferences.repository
import arrow.core.Either
import com.tangem.domain.models.wallet.UserWalletId
import com.tangem.domain.pushnotificationpreferences.models.PushNotificationCategory
import com.tangem.domain.pushnotificationpreferences.models.WalletPushNotificationPreferences
import kotlinx.coroutines.flow.Flow
/** Per-wallet push notification preferences. In-memory cache, not persisted. */
interface WalletPushNotificationPreferencesRepository {
/** Warms up the cache for [userWalletId]. No-op if already cached. */
suspend fun preload(userWalletId: UserWalletId)
fun observePreferences(userWalletId: UserWalletId): Flow<WalletPushNotificationPreferences>
/** Updates a single [category]; full-replace PUT under the hood. On failure cache is untouched. */
suspend fun updatePreference(
userWalletId: UserWalletId,
category: PushNotificationCategory,
isEnabled: Boolean,
): Either<Throwable, Unit>
}

View file

@ -1,7 +1,6 @@
package com.tangem.domain.staking.model.ethpool
import com.tangem.domain.models.serialization.SerializedBigDecimal
import kotlinx.serialization.Serializable
/**
* P2P.org transaction broadcast result
@ -9,21 +8,12 @@ import kotlinx.serialization.Serializable
*/
data class P2PEthPoolBroadcastResult(
val hash: String,
val status: P2PEthPoolBroadcastStatus,
val blockNumber: Int,
val transactionIndex: Int,
val gasUsed: SerializedBigDecimal,
val cumulativeGasUsed: SerializedBigDecimal,
val status: String,
val blockNumber: Int?,
val transactionIndex: Int?,
val gasUsed: SerializedBigDecimal?,
val cumulativeGasUsed: SerializedBigDecimal?,
val effectiveGasPrice: SerializedBigDecimal?,
val from: String,
val to: String,
)
/**
* Transaction broadcast status
*/
@Serializable
enum class P2PEthPoolBroadcastStatus {
SUCCESS, // Transaction confirmed successfully
FAILED, // Transaction failed
}
)

View file

@ -11,4 +11,9 @@ object P2PEthPoolStakingConfig {
val activeNetwork: P2PEthPoolNetwork
get() = if (USE_TESTNET) P2PEthPoolNetwork.TESTNET else P2PEthPoolNetwork.MAINNET
/** Vault addresses returned by the backend that should not be shown to users (test/stub vaults). Stored in lowercase. */
val TEST_VAULT_ADDRESSES: Set<String> = setOf(
"0xb72668d6ff7a0e318f83097a754c6aed0f8af034",
)
}

View file

@ -0,0 +1,16 @@
package com.tangem.domain.staking.model.ethpool
import java.math.BigDecimal
/**
* Per-vault capacity limits from Tangem API /v1/coins/settings.
*
* @property limit max stakeable amount in ETH (pre-computed as MAX_Threshold - TVL).
* Vaults absent from the API response or with null limit are not stored.
* @property coefficient threshold multiplier (e.g. 1.25×); optional server-side field,
* reserved for future use, not used in client-side calculations
*/
data class VaultLimitInfo(
val limit: BigDecimal,
val coefficient: BigDecimal?,
)

View file

@ -114,6 +114,7 @@ data class Yield(
@Serializable
data class Period(
val days: Int,
val seconds: Int?,
)
@Serializable

View file

@ -26,6 +26,7 @@ class FetchStakingOptionsUseCase(
coroutineScope {
launch { stakeKitRepository.fetchYields() }
launch { p2pEthPoolRepository.fetchVaults() }
launch { p2pEthPoolRepository.fetchVaultLimits() }
}
},
catch = { stakingErrorResolver.resolve(it) },

View file

@ -2,7 +2,7 @@ package com.tangem.domain.staking.model
sealed class CooldownPeriod {
data class Fixed(val days: Int) : CooldownPeriod()
data class Fixed(val period: Period) : CooldownPeriod()
data class Range(val minDays: Int, val maxDays: Int) : CooldownPeriod()
}

View file

@ -7,7 +7,9 @@ import com.tangem.domain.staking.model.common.RewardSchedule
import com.tangem.domain.staking.model.common.StakingActionArgs
import com.tangem.domain.staking.model.common.StakingAmountRequirement
import com.tangem.domain.staking.model.ethpool.P2PEthPoolVault
import com.tangem.domain.staking.model.ethpool.VaultLimitInfo
import java.math.BigDecimal
import java.math.RoundingMode
/**
* StakingIntegration implementation for P2PEthPool pooled staking.
@ -16,6 +18,7 @@ import java.math.BigDecimal
class P2PEthPoolIntegration(
override val integrationId: StakingIntegrationID,
private val vaults: List<P2PEthPoolVault>,
private val vaultLimits: Map<String, VaultLimitInfo>,
) : StakingIntegration {
// Basic
@ -30,9 +33,11 @@ class P2PEthPoolIntegration(
vault.toStakingTarget()
}
override val preferredTargets: List<StakingTarget> = targets
override val preferredTargets: List<StakingTarget> = vaults
.filter { isVaultAvailable(it) }
.map { it.toStakingTarget() }
override val areAllTargetsFull: Boolean = false
override val areAllTargetsFull: Boolean = preferredTargets.isEmpty()
// Enter/Exit Args
@ -40,12 +45,12 @@ class P2PEthPoolIntegration(
override val enterMinimumAmount: BigDecimal = DEFAULT_MINIMUM_STAKE
override val exitMinimumAmount: BigDecimal? = null
override val exitMinimumAmount: BigDecimal = DEFAULT_MINIMUM_UNSTAKE
override val enterArgs: StakingActionArgs = StakingActionArgs(
amountRequirement = StakingAmountRequirement(
isRequired = true,
minimum = DEFAULT_MINIMUM_STAKE,
minimum = enterMinimumAmount,
maximum = calculateMaximumStakeAmount(),
),
isPartialAmountDisabled = false,
@ -54,7 +59,7 @@ class P2PEthPoolIntegration(
override val exitArgs: StakingActionArgs = StakingActionArgs(
amountRequirement = StakingAmountRequirement(
isRequired = true,
minimum = null,
minimum = exitMinimumAmount,
maximum = null,
),
isPartialAmountDisabled = false,
@ -62,7 +67,7 @@ class P2PEthPoolIntegration(
// Metadata
override val warmupPeriodDays: Int = 0
override val warmupPeriod: Period = Period.Days(0)
override val cooldownPeriod: CooldownPeriod = CooldownPeriod.Range(
minDays = MIN_COOLDOWN_DAYS,
@ -82,19 +87,28 @@ class P2PEthPoolIntegration(
override fun getCurrentToken(rawCurrencyId: CryptoCurrency.RawID?): YieldToken = token
private fun isVaultAvailable(vault: P2PEthPoolVault): Boolean {
val info = vaultLimits[vault.vaultAddress.lowercase()] ?: return false
return info.limit - vault.totalAssets > AVAILABILITY_THRESHOLD
}
private fun calculateMaximumStakeAmount(): BigDecimal? {
return vaults
.filter { isVaultAvailable(it) }
.mapNotNull { vault ->
val availableCapacity = vault.capacity - vault.totalAssets
if (availableCapacity > BigDecimal.ZERO) availableCapacity else null
vaultLimits[vault.vaultAddress.lowercase()]?.let { it.limit - vault.totalAssets }
}
.maxOrNull()
.minOrNull()
?.setScale(MAX_AMOUNT_SCALE, RoundingMode.FLOOR)
}
companion object {
private const val MIN_COOLDOWN_DAYS = 1
private const val MAX_COOLDOWN_DAYS = 4
private const val MAX_AMOUNT_SCALE = 1
private val DEFAULT_MINIMUM_STAKE = BigDecimal("0.01")
private val DEFAULT_MINIMUM_UNSTAKE = BigDecimal("0.01")
private val AVAILABILITY_THRESHOLD = BigDecimal("0.1")
private const val TERMS_OF_SERVICE_URL = "https://www.p2p.org/terms-of-use"
private const val PRIVACY_POLICY_URL = "https://www.p2p.org/privacy-policy"

View file

@ -0,0 +1,14 @@
package com.tangem.domain.staking.model
sealed class Period {
abstract val value: Int
data class Days(
override val value: Int,
) : Period()
data class Seconds(
override val value: Int,
) : Period()
}

View file

@ -49,10 +49,22 @@ class StakeKitIntegration(
// Metadata
override val warmupPeriodDays: Int = yield.metadata.warmupPeriod.days
override val warmupPeriod: Period = yield.metadata.warmupPeriod.let { period ->
period.seconds?.let {
Period.Seconds(it)
} ?: period.days.let {
Period.Days(it)
}
}
override val cooldownPeriod: CooldownPeriod? = yield.metadata.cooldownPeriod?.days?.let {
CooldownPeriod.Fixed(it)
override val cooldownPeriod: CooldownPeriod? = yield.metadata.cooldownPeriod?.let { period ->
CooldownPeriod.Fixed(
period.seconds?.let {
Period.Seconds(it)
} ?: period.days.let {
Period.Days(it)
},
)
}
override val rewardSchedule: RewardSchedule = yield.metadata.rewardSchedule.toRewardSchedule()

View file

@ -4,7 +4,23 @@ sealed class StakingAvailability {
data class Available(val option: StakingOption) : StakingAvailability()
/**
* Integration exists and APY is known, but there is no free capacity (all vaults full).
* Existing stakes stay visible; new stakes are not offered. P2P ETH only.
*/
data class Full(val option: StakingOption) : StakingAvailability()
data object Unavailable : StakingAvailability()
data object TemporaryUnavailable : StakingAvailability()
}
}
/** Staking option if the integration is known (Available or Full), else null. */
val StakingAvailability.optionOrNull: StakingOption?
get() = when (this) {
is StakingAvailability.Available -> option
is StakingAvailability.Full -> option
StakingAvailability.Unavailable,
StakingAvailability.TemporaryUnavailable,
-> null
}

View file

@ -43,7 +43,7 @@ sealed interface StakingIntegration {
// Metadata
val warmupPeriodDays: Int
val warmupPeriod: Period
val cooldownPeriod: CooldownPeriod?

View file

@ -8,6 +8,7 @@ import com.tangem.domain.staking.model.ethpool.P2PEthPoolNetwork
import com.tangem.domain.staking.model.ethpool.P2PEthPoolUnsignedTx
import com.tangem.domain.staking.model.ethpool.P2PEthPoolVault
import com.tangem.domain.staking.model.ethpool.P2PEthPoolStakingConfig
import com.tangem.domain.staking.model.ethpool.VaultLimitInfo
import com.tangem.domain.staking.model.stakekit.StakingError
import kotlinx.coroutines.flow.Flow
@ -131,6 +132,25 @@ interface P2PEthPoolRepository {
*/
suspend fun getVaultsSync(): List<P2PEthPoolVault>
/**
* Fetch and store vault limits from Tangem API /v1/coins/settings
*/
suspend fun fetchVaultLimits()
/**
* Get flow of cached vault limits.
*
* @return Flow of map from vaultAddress.lowercase() to VaultLimitInfo, null if not yet fetched
*/
fun getVaultLimitsFlow(): Flow<Map<String, VaultLimitInfo>?>
/**
* Get cached vault limits synchronously.
*
* @return Map from vaultAddress.lowercase() to VaultLimitInfo, null if not yet fetched
*/
suspend fun getVaultLimitsSyncOrNull(): Map<String, VaultLimitInfo>?
/**
* Check P2PEthPool staking availability by finding public vault
*

View file

@ -0,0 +1,157 @@
package com.tangem.domain.staking.model
import com.google.common.truth.Truth.assertThat
import com.tangem.domain.staking.model.ethpool.P2PEthPoolVault
import com.tangem.domain.staking.model.ethpool.VaultLimitInfo
import org.junit.jupiter.api.Nested
import org.junit.jupiter.api.Test
import org.junit.jupiter.api.TestInstance
import java.math.BigDecimal
@TestInstance(TestInstance.Lifecycle.PER_CLASS)
internal class P2PEthPoolIntegrationTest {
private fun buildVault(
address: String,
capacity: String,
totalAssets: String,
) = P2PEthPoolVault(
vaultAddress = address,
displayName = "Test Vault",
apy = BigDecimal("4.5"),
baseApy = BigDecimal("4.0"),
capacity = BigDecimal(capacity),
totalAssets = BigDecimal(totalAssets),
feePercent = BigDecimal("0.1"),
isPrivate = false,
isGenesis = false,
isSmoothingPool = true,
isErc20 = false,
tokenName = null,
tokenSymbol = null,
createdAt = 0L,
)
private fun buildLimits(vararg pairs: Pair<String, BigDecimal>) =
pairs.associate { (addr, limit) ->
addr.lowercase() to VaultLimitInfo(limit = limit, coefficient = BigDecimal("1.25"))
}
@Nested
inner class MaximumAmount {
@Test
fun `vault available - uses remaining space as max, rounded down to 0_1 ETH`() {
val vaults = listOf(buildVault("0xABC", capacity = "100", totalAssets = "10"))
val limits = buildLimits("0xABC" to BigDecimal("50"))
val integration = P2PEthPoolIntegration(StakingIntegrationID.P2PEthPool, vaults, limits)
assertThat(integration.enterArgs!!.amountRequirement!!.maximum).isEqualTo(BigDecimal("40.0"))
}
@Test
fun `remaining with fractional ETH - floored to 0_1 ETH precision`() {
val vaults = listOf(buildVault("0xABC", capacity = "100", totalAssets = "10"))
val limits = buildLimits("0xABC" to BigDecimal("22.37"))
val integration = P2PEthPoolIntegration(StakingIntegrationID.P2PEthPool, vaults, limits)
assertThat(integration.enterArgs!!.amountRequirement!!.maximum).isEqualTo(BigDecimal("12.3"))
}
@Test
fun `vault absent from limits map - treated as full, max is null`() {
val vaults = listOf(buildVault("0xABC", capacity = "100", totalAssets = "30"))
val limits = emptyMap<String, VaultLimitInfo>()
val integration = P2PEthPoolIntegration(StakingIntegrationID.P2PEthPool, vaults, limits)
assertThat(integration.areAllTargetsFull).isTrue()
assertThat(integration.enterArgs!!.amountRequirement!!.maximum).isNull()
}
@Test
fun `multiple available vaults - uses minimum remaining space`() {
val vault1 = buildVault("0xA", capacity = "100", totalAssets = "10")
val vault2 = buildVault("0xB", capacity = "100", totalAssets = "20")
val limits = buildLimits("0xa" to BigDecimal("50"), "0xb" to BigDecimal("50"))
val integration = P2PEthPoolIntegration(StakingIntegrationID.P2PEthPool, listOf(vault1, vault2), limits)
assertThat(integration.enterArgs!!.amountRequirement!!.maximum).isEqualTo(BigDecimal("30.0"))
}
}
@Nested
inner class Availability {
@Test
fun `vault absent from limits - areAllTargetsFull is true`() {
val vaults = listOf(buildVault("0xABC", capacity = "100", totalAssets = "48"))
val limits = emptyMap<String, VaultLimitInfo>()
val integration = P2PEthPoolIntegration(StakingIntegrationID.P2PEthPool, vaults, limits)
assertThat(integration.areAllTargetsFull).isTrue()
}
@Test
fun `vault with exactly 0_1 ETH remaining - not available`() {
val vaults = listOf(buildVault("0xABC", capacity = "100", totalAssets = "49.9"))
val limits = buildLimits("0xABC" to BigDecimal("50")) // remaining = 0.1
val integration = P2PEthPoolIntegration(StakingIntegrationID.P2PEthPool, vaults, limits)
assertThat(integration.areAllTargetsFull).isTrue()
}
@Test
fun `vault with remaining just above 0_1 ETH - available (regression [REDACTED_TASK_KEY])`() {
val vaults = listOf(buildVault("0xABC", capacity = "400", totalAssets = "321.895202388313423922"))
val limits = buildLimits("0xABC" to BigDecimal("322.1")) // remaining ≈ 0.2048 > 0.1
val integration = P2PEthPoolIntegration(StakingIntegrationID.P2PEthPool, vaults, limits)
assertThat(integration.areAllTargetsFull).isFalse()
}
@Test
fun `at least one vault available - areAllTargetsFull is false`() {
val vault1 = buildVault("0xA", capacity = "100", totalAssets = "49.95") // full (0.05 remaining < 0.1)
val vault2 = buildVault("0xB", capacity = "100", totalAssets = "10") // available (40 remaining > 0.1)
val limits = buildLimits("0xa" to BigDecimal("50"), "0xb" to BigDecimal("50"))
val integration = P2PEthPoolIntegration(StakingIntegrationID.P2PEthPool, listOf(vault1, vault2), limits)
assertThat(integration.areAllTargetsFull).isFalse()
}
@Test
fun `preferred targets only contains available vaults`() {
val vault1 = buildVault("0xA", capacity = "100", totalAssets = "49.95") // full (0.05 remaining ≤ 0.1)
val vault2 = buildVault("0xB", capacity = "100", totalAssets = "10") // available (40 remaining > 0.1)
val limits = buildLimits("0xa" to BigDecimal("50"), "0xb" to BigDecimal("50"))
val integration = P2PEthPoolIntegration(StakingIntegrationID.P2PEthPool, listOf(vault1, vault2), limits)
assertThat(integration.preferredTargets).hasSize(1)
assertThat(integration.preferredTargets.first().address).isEqualTo("0xB")
}
}
@Nested
inner class MinimumAmount {
@Test
fun `minimum stake is 0_01 ETH`() {
val integration = P2PEthPoolIntegration(StakingIntegrationID.P2PEthPool, emptyList(), emptyMap())
assertThat(integration.enterMinimumAmount).isEqualTo(BigDecimal("0.01"))
}
@Test
fun `minimum unstake is 0_01 ETH`() {
val integration = P2PEthPoolIntegration(StakingIntegrationID.P2PEthPool, emptyList(), emptyMap())
assertThat(integration.exitMinimumAmount).isEqualTo(BigDecimal("0.01"))
}
@Test
fun `exit args expose minimum unstake requirement of 0_01 ETH`() {
val integration = P2PEthPoolIntegration(StakingIntegrationID.P2PEthPool, emptyList(), emptyMap())
val exitRequirement = integration.exitArgs!!.amountRequirement!!
assertThat(exitRequirement.isRequired).isTrue()
assertThat(exitRequirement.minimum).isEqualTo(BigDecimal("0.01"))
}
}
}

View file

@ -0,0 +1,206 @@
package com.tangem.domain.staking.model
import com.google.common.truth.Truth.assertThat
import com.tangem.domain.models.staking.NetworkType
import com.tangem.domain.models.staking.YieldToken
import com.tangem.domain.staking.model.stakekit.AddressArgument
import com.tangem.domain.staking.model.stakekit.Yield
import org.junit.jupiter.api.Nested
import org.junit.jupiter.api.Test
import org.junit.jupiter.api.TestInstance
import java.math.BigDecimal
/**
* Tests for [StakeKitIntegration] specifically the Period/CooldownPeriod mapping from [Yield.Metadata].
*/
@TestInstance(TestInstance.Lifecycle.PER_CLASS)
internal class StakeKitIntegrationTest {
// region helpers
private val dummyToken = YieldToken(
name = "Test Token",
network = NetworkType.SOLANA,
symbol = "SOL",
decimals = 9,
address = null,
coinGeckoId = null,
logoURI = null,
isPoints = false,
)
private val dummyEnter = Yield.Args.Enter(
addresses = Yield.Args.Enter.Addresses(
address = AddressArgument(required = false),
),
args = emptyMap(),
)
private val dummyArgs = Yield.Args(enter = dummyEnter, exit = null)
private val dummyStatus = Yield.Status(enter = true, exit = null)
private val dummyEnabled = Yield.Metadata.Enabled(enabled = true)
private fun buildYield(
warmupPeriod: Yield.Metadata.Period,
cooldownPeriod: Yield.Metadata.Period?,
): Yield {
return Yield(
id = "test-integration",
token = dummyToken,
tokens = emptyList(),
args = dummyArgs,
status = dummyStatus,
apy = BigDecimal("5.0"),
rewardRate = 5.0,
rewardType = com.tangem.domain.staking.model.common.RewardType.APY,
metadata = Yield.Metadata(
name = "Test Staking",
logoUri = "https://example.com/logo.png",
description = "Test staking integration",
documentation = null,
gasFeeToken = dummyToken,
token = dummyToken,
tokens = emptyList(),
type = "liquid",
rewardSchedule = Yield.Metadata.RewardSchedule.DAY,
cooldownPeriod = cooldownPeriod,
warmupPeriod = warmupPeriod,
rewardClaiming = Yield.Metadata.RewardClaiming.AUTO,
defaultValidator = null,
minimumStake = null,
supportsMultipleValidators = false,
revshare = dummyEnabled,
fee = dummyEnabled,
),
validators = emptyList(),
isAvailable = true,
)
}
private fun buildIntegration(
warmupPeriod: Yield.Metadata.Period,
cooldownPeriod: Yield.Metadata.Period?,
): StakeKitIntegration {
return StakeKitIntegration(
integrationId = StakingIntegrationID.StakeKit.Coin.Solana,
yield = buildYield(warmupPeriod = warmupPeriod, cooldownPeriod = cooldownPeriod),
)
}
// endregion
@Nested
@TestInstance(TestInstance.Lifecycle.PER_CLASS)
inner class `warmupPeriod mapping` {
@Test
fun `should produce Period Seconds when seconds is non-null`() {
// given
val warmup = Yield.Metadata.Period(days = 3, seconds = 7200)
// when
val integration = buildIntegration(warmupPeriod = warmup, cooldownPeriod = null)
// then
assertThat(integration.warmupPeriod).isEqualTo(Period.Seconds(7200))
}
@Test
fun `should produce Period Days when seconds is null`() {
// given
val warmup = Yield.Metadata.Period(days = 5, seconds = null)
// when
val integration = buildIntegration(warmupPeriod = warmup, cooldownPeriod = null)
// then
assertThat(integration.warmupPeriod).isEqualTo(Period.Days(5))
}
@Test
fun `should prefer seconds over days when both are present`() {
// given — days is non-zero but seconds takes priority
val warmup = Yield.Metadata.Period(days = 10, seconds = 3600)
// when
val integration = buildIntegration(warmupPeriod = warmup, cooldownPeriod = null)
// then
assertThat(integration.warmupPeriod).isInstanceOf(Period.Seconds::class.java)
assertThat((integration.warmupPeriod as Period.Seconds).value).isEqualTo(3600)
}
@Test
fun `should produce Period Days with zero value when days is zero and seconds is null`() {
// given
val warmup = Yield.Metadata.Period(days = 0, seconds = null)
// when
val integration = buildIntegration(warmupPeriod = warmup, cooldownPeriod = null)
// then
assertThat(integration.warmupPeriod).isEqualTo(Period.Days(0))
}
}
@Nested
@TestInstance(TestInstance.Lifecycle.PER_CLASS)
inner class `cooldownPeriod mapping` {
@Test
fun `should be null when yield cooldownPeriod is null`() {
// given
val warmup = Yield.Metadata.Period(days = 1, seconds = null)
// when
val integration = buildIntegration(warmupPeriod = warmup, cooldownPeriod = null)
// then
assertThat(integration.cooldownPeriod).isNull()
}
@Test
fun `should produce Fixed Period Seconds when cooldown seconds is non-null`() {
// given
val warmup = Yield.Metadata.Period(days = 1, seconds = null)
val cooldown = Yield.Metadata.Period(days = 2, seconds = 86400)
// when
val integration = buildIntegration(warmupPeriod = warmup, cooldownPeriod = cooldown)
// then
assertThat(integration.cooldownPeriod).isEqualTo(CooldownPeriod.Fixed(Period.Seconds(86400)))
}
@Test
fun `should produce Fixed Period Days when cooldown seconds is null`() {
// given
val warmup = Yield.Metadata.Period(days = 1, seconds = null)
val cooldown = Yield.Metadata.Period(days = 3, seconds = null)
// when
val integration = buildIntegration(warmupPeriod = warmup, cooldownPeriod = cooldown)
// then
assertThat(integration.cooldownPeriod).isEqualTo(CooldownPeriod.Fixed(Period.Days(3)))
}
@Test
fun `should prefer seconds over days in cooldown when both are present`() {
// given
val warmup = Yield.Metadata.Period(days = 1, seconds = null)
val cooldown = Yield.Metadata.Period(days = 7, seconds = 604800)
// when
val integration = buildIntegration(warmupPeriod = warmup, cooldownPeriod = cooldown)
// then
val period = integration.cooldownPeriod
assertThat(period).isInstanceOf(CooldownPeriod.Fixed::class.java)
assertThat((period as CooldownPeriod.Fixed).period).isInstanceOf(Period.Seconds::class.java)
assertThat((period.period as Period.Seconds).value).isEqualTo(604800)
}
}
}

View file

@ -5,7 +5,7 @@ plugins {
dependencies {
implementation(projects.domain.models)
implementation(projects.domain.promo.models)
implementation(projects.domain.stories.models)
implementation(projects.domain.settings)
implementation(projects.domain.wallets.models)

View file

@ -5,5 +5,4 @@ plugins {
}
dependencies {
implementation(deps.jodatime)
}

View file

@ -1,4 +1,4 @@
package com.tangem.domain.promo.models
package com.tangem.domain.stories.models
data class StoryContent(
val imageHost: String,
@ -25,4 +25,5 @@ data class StoryContent(
enum class StoryContentIds(val id: String, val analyticType: String) {
STORY_FIRST_TIME_SWAP(id = "first-time-swap-v2", analyticType = "Swap"),
STORY_FIRST_TIME_YIELD_PROMO(id = "first-time-yield-promo", analyticType = "YieldPromo"),
}

View file

@ -1,10 +1,10 @@
package com.tangem.domain.promo
package com.tangem.domain.stories
import arrow.core.Either
import arrow.core.left
import arrow.core.right
import com.tangem.domain.promo.models.StoryContent
import com.tangem.domain.promo.models.StoryContentIds
import com.tangem.domain.stories.models.StoryContent
import com.tangem.domain.stories.models.StoryContentIds
import com.tangem.domain.settings.repositories.SettingsRepository
import com.tangem.domain.settings.usercountry.models.needApplyFCARestrictions
import kotlinx.coroutines.FlowPreview
@ -12,7 +12,7 @@ import kotlinx.coroutines.flow.*
import kotlin.time.Duration.Companion.seconds
class GetStoryContentUseCase(
private val promoRepository: PromoRepository,
private val storiesRepository: StoriesRepository,
private val settingsRepository: SettingsRepository,
) {
@ -20,7 +20,7 @@ class GetStoryContentUseCase(
return isFCAAllowed(id).transform { isAllowed ->
if (isAllowed) {
emitAll(
promoRepository.getStoryById(id)
storiesRepository.getStoryById(id)
.map<StoryContent?, Either<Throwable, StoryContent?>> { it.right() }
.catch { emit(it.left()) }
.onEmpty { emit(null.right()) },
@ -34,7 +34,7 @@ class GetStoryContentUseCase(
suspend fun invokeSync(id: String, refresh: Boolean = false): Either<Throwable, StoryContent?> = Either.catch {
val isFCAAllowed = isFCAAllowed(id).firstOrNull() ?: false
return@catch if (isFCAAllowed) {
promoRepository.getStoryByIdSync(id, refresh)
storiesRepository.getStoryByIdSync(id, refresh)
} else {
null
}

View file

@ -0,0 +1,10 @@
package com.tangem.domain.stories
import kotlinx.coroutines.flow.Flow
class ShouldShowStoriesUseCase(private val storiesRepository: StoriesRepository) {
operator fun invoke(storyId: String): Flow<Boolean> = storiesRepository.isReadyToShowStories(storyId)
suspend fun invokeSync(storyId: String): Boolean = storiesRepository.isReadyToShowStoriesSync(storyId)
suspend fun neverToShow(storyId: String) = storiesRepository.setNeverToShowStories(storyId)
}

View file

@ -0,0 +1,19 @@
package com.tangem.domain.stories
import com.tangem.domain.stories.models.StoryContent
import kotlinx.coroutines.flow.Flow
interface StoriesRepository {
// region Stories
fun getStoryById(id: String): Flow<StoryContent?>
suspend fun getStoryByIdSync(id: String, refresh: Boolean): StoryContent?
fun isReadyToShowStories(storyId: String): Flow<Boolean>
suspend fun isReadyToShowStoriesSync(storyId: String): Boolean
suspend fun setNeverToShowStories(storyId: String)
// endregion
}

View file

@ -30,4 +30,8 @@ dependencies {
implementation(deps.kotlin.coroutines)
implementation(deps.jodatime)
/** Tests */
testImplementation(deps.test.junit)
testImplementation(deps.test.truth)
testImplementation(deps.test.mockk)
}

View file

@ -0,0 +1,10 @@
package com.tangem.domain.swap.models
import java.math.BigDecimal
enum class PredefinedPercentAmount(val percent: BigDecimal) {
PERCENT_25(BigDecimal("0.25")),
PERCENT_50(BigDecimal("0.50")),
PERCENT_75(BigDecimal("0.75")),
MAX(BigDecimal.ONE),
}

View file

@ -0,0 +1,14 @@
package com.tangem.domain.swap.usecase
import com.tangem.domain.swap.models.PredefinedPercentAmount
import java.math.BigDecimal
import java.math.RoundingMode
class CalculateAmountUseCase {
operator fun invoke(balance: BigDecimal, decimals: Int, percent: PredefinedPercentAmount): BigDecimal {
return balance
.multiply(percent.percent)
.setScale(decimals, RoundingMode.DOWN)
}
}

View file

@ -0,0 +1,151 @@
package com.tangem.domain.swap.usecase
import com.google.common.truth.Truth.assertThat
import com.tangem.domain.swap.models.PredefinedPercentAmount
import org.junit.Test
import java.math.BigDecimal
class CalculateAmountUseCaseTest {
private val useCase = CalculateAmountUseCase()
@Test
fun `GIVEN balance and PERCENT_25 WHEN invoke THEN return one quarter of balance`() {
val balance = BigDecimal("100")
val decimals = 2
val result = useCase(
balance = balance,
decimals = decimals,
percent = PredefinedPercentAmount.PERCENT_25,
)
assertThat(result).isEqualTo(BigDecimal("25.00"))
}
@Test
fun `GIVEN balance and PERCENT_50 WHEN invoke THEN return half of balance`() {
val balance = BigDecimal("100")
val decimals = 2
val result = useCase(
balance = balance,
decimals = decimals,
percent = PredefinedPercentAmount.PERCENT_50,
)
assertThat(result).isEqualTo(BigDecimal("50.00"))
}
@Test
fun `GIVEN balance and PERCENT_75 WHEN invoke THEN return three quarters of balance`() {
val balance = BigDecimal("100")
val decimals = 2
val result = useCase(
balance = balance,
decimals = decimals,
percent = PredefinedPercentAmount.PERCENT_75,
)
assertThat(result).isEqualTo(BigDecimal("75.00"))
}
@Test
fun `GIVEN balance and MAX WHEN invoke THEN return full balance`() {
val balance = BigDecimal("100")
val decimals = 2
val result = useCase(
balance = balance,
decimals = decimals,
percent = PredefinedPercentAmount.MAX,
)
assertThat(result).isEqualTo(BigDecimal("100.00"))
}
@Test
fun `GIVEN zero balance WHEN invoke THEN return zero with decimals scale`() {
val balance = BigDecimal.ZERO
val decimals = 6
val result = useCase(
balance = balance,
decimals = decimals,
percent = PredefinedPercentAmount.PERCENT_50,
)
assertThat(result).isEqualTo(BigDecimal("0.000000"))
}
@Test
fun `GIVEN balance with more precision than decimals WHEN invoke THEN truncate result with rounding down`() {
val balance = BigDecimal("1.999999999999999999")
val decimals = 6
val result = useCase(
balance = balance,
decimals = decimals,
percent = PredefinedPercentAmount.PERCENT_25,
)
assertThat(result).isEqualTo(BigDecimal("0.499999"))
}
@Test
fun `GIVEN fractional percent product WHEN invoke THEN round down to decimals scale`() {
val balance = BigDecimal("1")
val decimals = 1
val result = useCase(
balance = balance,
decimals = decimals,
percent = PredefinedPercentAmount.PERCENT_75,
)
assertThat(result).isEqualTo(BigDecimal("0.7"))
}
@Test
fun `GIVEN zero decimals WHEN invoke THEN return integer value rounded down`() {
val balance = BigDecimal("9")
val decimals = 0
val result = useCase(
balance = balance,
decimals = decimals,
percent = PredefinedPercentAmount.PERCENT_75,
)
assertThat(result).isEqualTo(BigDecimal("6"))
}
@Test
fun `GIVEN high-precision balance and MAX WHEN invoke THEN preserve balance truncated to decimals`() {
val balance = BigDecimal("12.3456789012345678")
val decimals = 8
val result = useCase(
balance = balance,
decimals = decimals,
percent = PredefinedPercentAmount.MAX,
)
assertThat(result).isEqualTo(BigDecimal("12.34567890"))
}
@Test
fun `GIVEN large balance and PERCENT_50 WHEN invoke THEN return correctly scaled half`() {
val balance = BigDecimal("123456789.987654321")
val decimals = 4
val result = useCase(
balance = balance,
decimals = decimals,
percent = PredefinedPercentAmount.PERCENT_50,
)
assertThat(result).isEqualTo(BigDecimal("61728394.9938"))
}
}

View file

@ -34,8 +34,8 @@ dependencies {
implementation(projects.domain.settings)
implementation(projects.features.swap.domain.api)
implementation(projects.features.swap.domain.models)
implementation(projects.domain.promo.models)
implementation(projects.domain.promo)
implementation(projects.domain.stories.models)
implementation(projects.domain.stories)
implementation(projects.domain.networks)
implementation(projects.domain.quotes)
implementation(projects.domain.yieldSupply.models)

View file

@ -12,7 +12,7 @@ dependencies {
implementation(projects.domain.models)
implementation(projects.domain.txhistory.models)
implementation(projects.domain.staking.models)
implementation(projects.domain.promo.models)
implementation(projects.domain.stories.models)
/** Other dependencies */
implementation(deps.kotlin.serialization)

View file

@ -61,4 +61,7 @@ sealed class ScenarioUnavailabilityReason {
enum class WithdrawalScenario {
SELL, SEND // TODO staking create&process STAKING
}
}
}
val ScenarioUnavailabilityReason.isLoading: Boolean
get() = this == ScenarioUnavailabilityReason.DataLoading || this is ScenarioUnavailabilityReason.ExpressLoading

View file

@ -1,65 +0,0 @@
package com.tangem.domain.tokens.model.analytics
import com.tangem.core.analytics.models.AnalyticsEvent
import com.tangem.core.analytics.models.AnalyticsParam
sealed class PromoAnalyticsEvent(
event: String,
params: Map<String, String> = emptyMap(),
) : AnalyticsEvent(category = "Promotion", event = event, params = params) {
data class NoticePromotionBanner(
private val source: AnalyticsParam.ScreensSources,
private val program: Program,
) : PromoAnalyticsEvent(
event = "Notice - Promotion Banner",
params = mapOf(
AnalyticsParam.SOURCE to source.value,
"Program Name" to program.programName,
),
)
data class PromotionBannerClicked(
private val source: AnalyticsParam.ScreensSources,
private val program: Program,
private val action: BannerAction,
) : PromoAnalyticsEvent(
event = "Promo Banner Clicked",
params = mapOf(
AnalyticsParam.SOURCE to source.value,
"Program Name" to program.programName,
"Action" to action.action,
),
) {
sealed class BannerAction(val action: String) {
class Clicked : BannerAction(action = "Clicked")
class Closed : BannerAction(action = "Closed")
}
}
// region visa waitlist promo
class VisaWaitlistPromo : PromoAnalyticsEvent(event = "Visa Waitlist")
class VisaWaitlistPromoJoin : PromoAnalyticsEvent(
event = "Button - Join Now",
params = mapOf(
"Program Name" to "Visa Waitlist",
),
)
class VisaWaitlistPromoDismiss : PromoAnalyticsEvent(
event = "Button - Close",
params = mapOf(
"Program Name" to "Visa Waitlist",
),
)
//endregion
// Use it on new promo action
enum class Program(val programName: String) {
Empty("Empty"),
Sepa("Sepa"),
BlackFriday("Black Friday"),
OnePlusOne("One-Plus-One"),
YieldPromo("Yield Promo"),
}
}

View file

@ -22,14 +22,18 @@ sealed class TokenScreenAnalyticsEvent(
blockchain: String,
token: String,
tokenBalance: TokenBalance,
isDynamicAddress: Boolean? = null,
) : AnalyticsEvent(
category = "Details Screen",
event = "Details Screen Opened",
params = mapOf(
BLOCKCHAIN to blockchain,
TOKEN_PARAM to token,
BALANCE to tokenBalance.name,
),
params = buildMap {
put(BLOCKCHAIN, blockchain)
put(TOKEN_PARAM, token)
put(BALANCE, tokenBalance.name)
isDynamicAddress?.let {
put("Dynamic Address", if (it) "True" else "False")
}
},
) {
sealed class TokenBalance(val name: String) {
data object Full : TokenBalance("Full")

View file

@ -1,8 +1,6 @@
package com.tangem.domain.tokens.model.warnings
import com.tangem.domain.models.currency.CryptoCurrency
import com.tangem.domain.promo.models.PromoId
import org.joda.time.DateTime
import java.math.BigDecimal
sealed class CryptoCurrencyWarning {
@ -47,12 +45,6 @@ sealed class CryptoCurrencyWarning {
val cryptoCurrency: CryptoCurrency,
) : CryptoCurrencyWarning()
data class SwapPromo(
val promoId: PromoId,
val startDateTime: DateTime,
val endDateTime: DateTime,
) : CryptoCurrencyWarning()
data object BeaconChainShutdown : CryptoCurrencyWarning()
data object MigrationMaticToPol : CryptoCurrencyWarning()

View file

@ -51,7 +51,9 @@ class BalanceFetchingOperations(
async {
val result = when (source) {
FetchingSource.NETWORK -> fetchNetworks(userWalletId, currencies)
FetchingSource.QUOTE -> fetchQuotes(currencies)
FetchingSource.QUOTE -> fetchQuotes(
currencies.mapNotNullTo(hashSetOf()) { it.id.rawCurrencyId },
)
FetchingSource.STAKING -> fetchStaking(userWalletId, currencies)
}
source to result
@ -85,17 +87,14 @@ class BalanceFetchingOperations(
}
/**
* Fetches quotes for the given currencies.
* Fetches quotes for the given raw currency ids.
*
* @param currencies the cryptocurrencies to fetch quotes for
* @param rawCurrencyIds the raw currency ids to fetch quotes for
* @return Either with Unit on success or Throwable on failure
*/
suspend fun fetchQuotes(currencies: Collection<CryptoCurrency>): Either<Throwable, Unit> {
suspend fun fetchQuotes(rawCurrencyIds: Set<CryptoCurrency.RawID>): Either<Throwable, Unit> {
return multiQuoteStatusFetcher(
params = MultiQuoteStatusFetcher.Params(
currenciesIds = currencies.mapNotNullTo(hashSetOf()) { it.id.rawCurrencyId },
appCurrencyId = null,
),
params = MultiQuoteStatusFetcher.Params(currenciesIds = rawCurrencyIds, appCurrencyId = null),
)
}

View file

@ -6,9 +6,9 @@ import com.tangem.domain.models.currency.CryptoCurrency
import com.tangem.domain.models.currency.CryptoCurrencyStatus
import com.tangem.domain.models.wallet.UserWallet
import com.tangem.domain.models.wallet.UserWalletId
import com.tangem.domain.promo.PromoRepository
import com.tangem.domain.promo.models.StoryContent
import com.tangem.domain.promo.models.StoryContentIds
import com.tangem.domain.stories.StoriesRepository
import com.tangem.domain.stories.models.StoryContent
import com.tangem.domain.stories.models.StoryContentIds
import com.tangem.domain.staking.model.StakingAvailability
import com.tangem.domain.staking.repositories.StakingRepository
import com.tangem.domain.tokens.actions.CommonActionsFactory
@ -28,14 +28,14 @@ import kotlinx.coroutines.flow.*
* @param rampManager the manager for handling ramp state operations
* @param walletManagersFacade the facade for managing wallet operations
* @property stakingRepository the repository for staking-related data
* @property promoRepository the repository for promotional content
* @property storiesRepository the repository for stories content
* @property dispatchers the coroutine dispatcher provider for managing concurrency
*/
class GetCryptoCurrencyActionsUseCase(
rampManager: RampStateManager,
walletManagersFacade: WalletManagersFacade,
private val stakingRepository: StakingRepository,
private val promoRepository: PromoRepository,
private val storiesRepository: StoriesRepository,
private val dispatchers: CoroutineDispatcherProvider,
) {
@ -127,7 +127,7 @@ class GetCryptoCurrencyActionsUseCase(
}
private fun getSwapStoryContent(): Flow<StoryContent?> {
return promoRepository.getStoryById(StoryContentIds.STORY_FIRST_TIME_SWAP.id)
return storiesRepository.getStoryById(StoryContentIds.STORY_FIRST_TIME_SWAP.id)
.conflate()
.distinctUntilChanged()
}

View file

@ -174,17 +174,18 @@ internal open class BaseActionsFactory(
protected fun createStakingAction(
currency: CryptoCurrency,
stakingAvailability: StakingAvailability,
): ActionState.Stake {
return if (stakingAvailability is StakingAvailability.Available) {
ActionState.Stake(
): ActionState.Stake? {
return when (stakingAvailability) {
is StakingAvailability.Available -> ActionState.Stake(
unavailabilityReason = ScenarioUnavailabilityReason.None,
option = stakingAvailability.option,
)
} else {
ActionState.Stake(
StakingAvailability.TemporaryUnavailable -> ActionState.Stake(
unavailabilityReason = ScenarioUnavailabilityReason.StakingUnavailable(currency.name),
option = null,
)
is StakingAvailability.Full -> null
StakingAvailability.Unavailable -> null
}
}

View file

@ -81,7 +81,7 @@ internal class CommonActionsFactory(
// region Stake
createStakingAction(currency = cryptoCurrencyStatus.currency, stakingAvailability = stakingAvailability)
.addByReason()
?.addByReason()
// endregion
val sendUnavailabilityReason = sendUnavailabilityReasonDeferred.await()

View file

@ -97,7 +97,7 @@ internal class OutdatedDataActionsFactory(
stakingAvailability = stakingAvailability,
)
stakingAction.addByReason()
stakingAction?.addByReason()
} else {
val stakingAction = ActionState.Stake(
unavailabilityReason = ScenarioUnavailabilityReason.UsedOutdatedData,

View file

@ -13,6 +13,7 @@ import com.tangem.domain.models.currency.CryptoCurrency
import com.tangem.domain.models.wallet.UserWallet
import com.tangem.domain.models.wallet.UserWalletId
import com.tangem.domain.networks.multi.MultiNetworkStatusFetcher
import com.tangem.domain.pay.TangemPayCurrencyFactory
import com.tangem.domain.pay.flow.PaymentAccountStatusFetcher
import com.tangem.domain.quotes.multi.MultiQuoteStatusFetcher
import com.tangem.domain.staking.StakingIdFactory
@ -173,6 +174,7 @@ class WalletBalanceFetcher internal constructor(
// Fetch TangemPay separately — may run long-polling, so it must not block balance error checking
if (fetchingSources.any { it is WalletFetchingSource.TangemPay }) {
balanceFetchingOperations.fetchQuotes(rawCurrencyIds = setOf(TangemPayCurrencyFactory.TOKEN_ID))
paymentAccountStatusFetcher.invoke(PaymentAccountStatusFetcher.Params(userWalletId))
}
}

View file

@ -65,14 +65,15 @@ class AssociateAssetUseCase(
private fun createSigner(userWallet: UserWallet): TransactionSigner {
return when (userWallet) {
is UserWallet.Hot -> getHotTransactionSigner(userWallet)
is UserWallet.Cold -> getColdSigner()
is UserWallet.Cold -> getColdSigner(userWallet)
}
}
private fun getColdSigner(): TransactionSigner {
private fun getColdSigner(userWallet: UserWallet.Cold): TransactionSigner {
return cardSdkConfigRepository.getCommonSigner(
cardId = null,
twinKey = null, // use null here because no assets support for Twin cards
userWalletId = userWallet.walletId,
)
}

View file

@ -7,14 +7,14 @@ import com.tangem.blockchain.common.AmountType
import com.tangem.blockchain.common.Token
import com.tangem.blockchain.common.TransactionData
import com.tangem.blockchain.extensions.Result
import com.tangem.domain.demo.models.DemoConfig
import com.tangem.domain.demo.DemoTransactionSender
import com.tangem.domain.demo.models.DemoConfig
import com.tangem.domain.models.currency.CryptoCurrency
import com.tangem.domain.models.network.Network
import com.tangem.domain.models.wallet.UserWallet
import com.tangem.domain.transaction.error.GetFeeError
import com.tangem.domain.transaction.error.mapToFeeError
import com.tangem.domain.walletmanager.WalletManagersFacade
import com.tangem.domain.models.wallet.UserWallet
import java.math.BigDecimal
/**

View file

@ -62,14 +62,15 @@ class OpenTrustlineUseCase(
private fun createSigner(userWallet: UserWallet): TransactionSigner {
return when (userWallet) {
is UserWallet.Hot -> getHotTransactionSigner(userWallet)
is UserWallet.Cold -> getColdSigner()
is UserWallet.Cold -> getColdSigner(userWallet)
}
}
private fun getColdSigner(): TransactionSigner {
private fun getColdSigner(userWallet: UserWallet.Cold): TransactionSigner {
return cardSdkConfigRepository.getCommonSigner(
cardId = null,
twinKey = null, // use null here because no assets support for Twin cards
userWalletId = userWallet.walletId,
)
}
}

View file

@ -70,6 +70,7 @@ class PrepareAndSignUseCase(
val signer = cardSdkConfigRepository.getCommonSigner(
cardId = card.cardId.takeIf { isCardNotBackedUp },
twinKey = TwinKey.getOrNull(scanResponse = userWallet.scanResponse),
userWalletId = userWallet.walletId,
)
return signer
}

View file

@ -69,6 +69,7 @@ class PrepareForSendUseCase(
val signer = cardSdkConfigRepository.getCommonSigner(
cardId = card.cardId.takeIf { isCardNotBackedUp },
twinKey = TwinKey.getOrNull(scanResponse = userWallet.scanResponse),
userWalletId = userWallet.walletId,
)
return signer
}

View file

@ -58,14 +58,15 @@ class RetryIncompleteTransactionUseCase(
private fun createSigner(userWallet: UserWallet): TransactionSigner {
return when (userWallet) {
is UserWallet.Hot -> getHotTransactionSigner(userWallet)
is UserWallet.Cold -> getColdSigner()
is UserWallet.Cold -> getColdSigner(userWallet)
}
}
private fun getColdSigner(): TransactionSigner {
private fun getColdSigner(userWallet: UserWallet.Cold): TransactionSigner {
return cardSdkConfigRepository.getCommonSigner(
cardId = null,
twinKey = null, // use null here because no assets support for Twin cards
userWalletId = userWallet.walletId,
)
}
}

View file

@ -36,6 +36,7 @@ class SendLargeSolanaTransactionUseCase(
val signer = cardSdkConfigRepository.getCommonSigner(
cardId = card.cardId.takeIf { isCardNotBackedUp },
twinKey = TwinKey.getOrNull(scanResponse = userWallet.scanResponse),
userWalletId = userWallet.walletId,
)
val walletManager = walletManagersFacade

View file

@ -62,6 +62,7 @@ class SendTransactionUseCase(
val coldSigner = cardSdkConfigRepository.getCommonSigner(
cardId = card.cardId.takeIf { isCardNotBackedUp },
twinKey = TwinKey.getOrNull(scanResponse = userWallet.scanResponse),
userWalletId = userWallet.walletId,
)
coldSigner

View file

@ -43,6 +43,7 @@ class SignCloreMessageUseCase(
cardSdkConfigRepository.getCommonSigner(
cardId = card.cardId.takeIf { isCardNotBackedUp },
twinKey = null,
userWalletId = userWallet.walletId,
)
}
is UserWallet.Hot -> getHotWalletSigner(userWallet)

View file

@ -44,6 +44,7 @@ class SignUseCase(
return cardSdkConfigRepository.getCommonSigner(
cardId = card.cardId.takeIf { isCardNotBackedUp },
twinKey = TwinKey.getOrNull(scanResponse = userWallet.scanResponse),
userWalletId = userWallet.walletId,
)
}
}

View file

@ -235,6 +235,7 @@ class CreateAndSendGaslessTransactionUseCase(
cardSdkConfigRepository.getCommonSigner(
cardId = card.cardId.takeIf { isCardNotBackedUp },
twinKey = TwinKey.getOrNull(scanResponse = userWallet.scanResponse),
userWalletId = userWallet.walletId,
)
}
is UserWallet.Hot -> getHotWalletSigner(userWallet)

View file

@ -28,6 +28,7 @@ dependencies {
implementation(projects.domain.core)
implementation(projects.domain.tokens.models)
implementation(projects.domain.wallets.models)
implementation(projects.core.datasource)
/** Security */
implementation(deps.spongecastle.core)

View file

@ -1,18 +0,0 @@
package com.tangem.domain.pay
import com.tangem.domain.models.account.CardDisplayName
import com.tangem.domain.visa.model.TangemPayCardFrozenState
import kotlinx.serialization.Serializable
@Serializable
data class TangemPayDetailsConfig(
val customerId: String,
val cardId: String,
val isPinSet: Boolean,
val cardFrozenState: TangemPayCardFrozenState,
val cardNumberEnd: String,
val isReissuing: Boolean,
val chainId: Int,
val isTangemPayDeactivated: Boolean,
val displayName: CardDisplayName?,
)

View file

@ -0,0 +1,19 @@
package com.tangem.domain.visa.model
enum class TangemPayPushNotificationType(val value: String) {
CARD_READY("card_ready"),
TRANSACTION_SPEND("transaction_spend"),
DECLINED_TOP_UP("declined_top_up"),
COLLATERAL_WITHDRAW("collateral_withdraw"),
COLLATERAL_DEPOSIT("collateral_deposit"),
TRANSACTION_SPEND_REFUND("transaction_spend_refund"),
;
companion object {
private val map = entries.associateBy { it.value }
val all: Set<String> = entries.map { it.value }.toSet()
fun fromValue(value: String): TangemPayPushNotificationType? = map[value]
}
}

View file

@ -1,12 +0,0 @@
package com.tangem.domain.pay
import arrow.core.Either
import com.tangem.core.error.UniversalError
import com.tangem.domain.models.currency.CryptoCurrency
import com.tangem.domain.models.wallet.UserWallet
@Deprecated("TangemPayCurrencyFactory")
interface TangemPayCryptoCurrencyFactory {
fun create(userWallet: UserWallet, chainId: Int): Either<UniversalError, CryptoCurrency>
}

View file

@ -0,0 +1,29 @@
package com.tangem.domain.pay
import com.tangem.domain.models.currency.CryptoCurrency
import com.tangem.domain.models.wallet.UserWalletId
/**
* Factory that builds the [CryptoCurrency.Token] used by Tangem Pay (USDC on Polygon) for a given user wallet.
*
* Replaces the deprecated `TangemPayCryptoCurrencyFactory`: callers no longer pass the chain id explicitly
* the underlying network is resolved from the wallet.
*/
interface TangemPayCurrencyFactory {
/**
* Builds the Tangem Pay token bound to the network of the wallet identified by [userWalletId].
*
* @throws IllegalStateException if no wallet with [userWalletId] is currently loaded.
*/
fun create(userWalletId: UserWalletId): CryptoCurrency.Token
/** Hardcoded token metadata for the Tangem Pay currency (USDC on Polygon). */
companion object {
/** CoinGecko-style raw id used to query quotes for the Tangem Pay token. */
val TOKEN_ID = CryptoCurrency.RawID("usd-coin")
const val TOKEN_NAME = "USDC"
const val TOKEN_CONTRACT_ADDRESS = "0x3c499c542cef5e3811e1192ce70d8cc03d5c3359"
const val TOKEN_DECIMALS = 6
}
}

View file

@ -1,9 +1,9 @@
package com.tangem.domain.pay.model
import com.tangem.domain.models.pay.TangemPayCardLimit
import com.tangem.domain.models.account.CardDisplayName
import com.tangem.domain.models.account.PaymentAccountStatusValue
import com.tangem.domain.models.kyc.KycStatus
import com.tangem.domain.models.pay.TangemPayCardLimit
import com.tangem.domain.visa.model.TangemPayCardFrozenState
import java.math.BigDecimal
import java.util.Locale
@ -27,6 +27,7 @@ data class CustomerInfo(
val cardInfo: CardInfo?,
val state: State,
val fiatBalance: PaymentAccountStatusValue.FiatBalance?,
val cryptoBalance: PaymentAccountStatusValue.CryptoBalance?,
) {
enum class State {
NEW,

View file

@ -4,6 +4,7 @@ import arrow.core.Either
import com.tangem.core.error.UniversalError
import com.tangem.domain.models.currency.CryptoCurrency
import com.tangem.domain.models.wallet.UserWallet
import com.tangem.domain.models.wallet.UserWalletId
import com.tangem.domain.pay.TangemPayWithdrawExchangeState
import com.tangem.domain.pay.WithdrawalResult
import java.math.BigDecimal
@ -18,7 +19,7 @@ interface TangemPayWithdrawRepository {
exchangeData: TangemPayWithdrawExchangeState,
): Either<UniversalError, WithdrawalResult>
suspend fun hasWithdrawOrder(userWallet: UserWallet): Boolean
suspend fun hasWithdrawOrder(userWalletId: UserWalletId): Boolean
suspend fun pollWithdrawOrdersIfNeeds(userWallet: UserWallet)
}

View file

@ -0,0 +1,17 @@
package com.tangem.domain.pay.utils
import com.tangem.domain.visa.model.TangemPayTxHistoryItem
import com.tangem.utils.converter.Converter
object TangemPayTxHistoryItemStatusConverter : Converter<String, TangemPayTxHistoryItem.Status> {
override fun convert(value: String): TangemPayTxHistoryItem.Status {
return when (value.uppercase()) {
"PENDING" -> TangemPayTxHistoryItem.Status.PENDING
"RESERVED" -> TangemPayTxHistoryItem.Status.RESERVED
"COMPLETED" -> TangemPayTxHistoryItem.Status.COMPLETED
"DECLINED" -> TangemPayTxHistoryItem.Status.DECLINED
"REVERSED" -> TangemPayTxHistoryItem.Status.REVERSED
else -> TangemPayTxHistoryItem.Status.UNKNOWN
}
}
}

Some files were not shown because too many files have changed in this diff Show more