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

@ -1,5 +1,8 @@
package com.tangem.data.account.utils
import com.tangem.blockchain.common.Blockchain
import com.tangem.core.configtoggle.FeatureToggles
import com.tangem.core.configtoggle.feature.FeatureTogglesManager
import com.tangem.data.account.converter.CryptoPortfolioConverter
import com.tangem.data.common.currency.UserTokensResponseFactory
import com.tangem.data.common.network.NetworkFactory
@ -31,6 +34,7 @@ internal class DefaultWalletAccountsResponseFactory @Inject constructor(
private val cryptoPortfolioCF: CryptoPortfolioConverter.Factory,
private val userTokensResponseFactory: UserTokensResponseFactory,
private val networkFactory: NetworkFactory,
private val featureTogglesManager: FeatureTogglesManager,
) {
fun create(userWalletId: UserWalletId, userTokensResponse: UserTokensResponse?): GetWalletAccountsResponse {
@ -69,6 +73,21 @@ internal class DefaultWalletAccountsResponseFactory @Inject constructor(
accountId = userWallet?.let {
AccountId.forCryptoPortfolio(userWalletId = it.walletId, derivationIndex = DerivationIndex.Main)
},
extraBlockchains = userWallet?.extraDefaultBlockchains().orEmpty(),
)
}
private fun UserWallet.extraDefaultBlockchains(): List<Blockchain> {
val batchId = (this as? UserWallet.Cold)?.scanResponse?.card?.batchId ?: return emptyList()
return when {
batchId == ADI_PROMO_BATCH_ID &&
featureTogglesManager.isFeatureEnabled(FeatureToggles.AND_15402_ADI_MAIN_SCREEN_DEFAULT_ENABLED) ->
listOf(Blockchain.Adi)
else -> emptyList()
}
}
private companion object {
const val ADI_PROMO_BATCH_ID = "BB000053"
}
}

View file

@ -1,6 +1,9 @@
package com.tangem.data.account.utils
import com.google.common.truth.Truth
import com.tangem.blockchain.common.Blockchain
import com.tangem.core.configtoggle.FeatureToggles
import com.tangem.core.configtoggle.feature.FeatureTogglesManager
import com.tangem.data.account.converter.CryptoPortfolioConverter
import com.tangem.data.account.converter.createWalletAccountDTO
import com.tangem.data.account.utils.GetWalletAccountsResponseExtTest.Companion.createUserToken
@ -32,12 +35,14 @@ class DefaultWalletAccountsResponseFactoryTest {
private val cryptoPortfolioConverter = mockk<CryptoPortfolioConverter>()
private val userTokensResponseFactory = mockk<UserTokensResponseFactory>()
private val networkFactory = mockk<NetworkFactory>()
private val featureTogglesManager = mockk<FeatureTogglesManager>()
private val factory = DefaultWalletAccountsResponseFactory(
userWalletsListRepository = userWalletsListRepository,
cryptoPortfolioCF = cryptoPortfolioCF,
userTokensResponseFactory = userTokensResponseFactory,
networkFactory = networkFactory,
featureTogglesManager = featureTogglesManager,
)
private val userWalletId = UserWalletId("011")
@ -75,6 +80,7 @@ class DefaultWalletAccountsResponseFactoryTest {
userWallet = null,
networkFactory = networkFactory,
accountId = null,
extraBlockchains = emptyList(),
)
} returns userTokensResponse
@ -100,6 +106,7 @@ class DefaultWalletAccountsResponseFactoryTest {
userWallet = null,
networkFactory = networkFactory,
accountId = null,
extraBlockchains = emptyList(),
)
}
}
@ -129,6 +136,7 @@ class DefaultWalletAccountsResponseFactoryTest {
userWallet = userWallet,
networkFactory = networkFactory,
accountId = accounts.first().accountId,
extraBlockchains = emptyList(),
)
} returns defaultResponse
@ -159,6 +167,7 @@ class DefaultWalletAccountsResponseFactoryTest {
userWallet = userWallet,
networkFactory = networkFactory,
accountId = accounts.first().accountId,
extraBlockchains = emptyList(),
)
}
}
@ -188,6 +197,7 @@ class DefaultWalletAccountsResponseFactoryTest {
userWallet = userWallet,
networkFactory = networkFactory,
accountId = accounts.first().accountId,
extraBlockchains = emptyList(),
)
} returns defaultResponse
@ -210,6 +220,138 @@ class DefaultWalletAccountsResponseFactoryTest {
Truth.assertThat(actual).isEqualTo(expected)
}
@Test
fun `create passes ADI as extra blockchain when batch is BB000053 and toggle is on`() = runTest {
// Arrange
val userWallet = mockk<UserWallet.Cold>(relaxed = true) {
every { walletId } returns userWalletId
every { scanResponse.card.batchId } returns "BB000053"
}
every { userWalletsListRepository.userWallets } returns MutableStateFlow(listOf(userWallet))
every {
featureTogglesManager.isFeatureEnabled(FeatureToggles.AND_15402_ADI_MAIN_SCREEN_DEFAULT_ENABLED)
} returns true
val accounts = AccountList.empty(userWallet.walletId).accounts
.filterIsInstance<Account.CryptoPortfolio>()
val defaultResponse = UserTokensResponse(
group = UserTokensResponse.GroupType.NETWORK,
sort = UserTokensResponse.SortType.BALANCE,
tokens = emptyList(),
)
every {
userTokensResponseFactory.createDefaultResponse(
userWallet = userWallet,
networkFactory = networkFactory,
accountId = accounts.first().accountId,
extraBlockchains = listOf(Blockchain.Adi),
)
} returns defaultResponse
every { cryptoPortfolioConverter.convertListBack(accounts) } returns emptyList()
// Act
factory.create(userWalletId, null)
// Assert
coVerifyOrder {
userTokensResponseFactory.createDefaultResponse(
userWallet = userWallet,
networkFactory = networkFactory,
accountId = accounts.first().accountId,
extraBlockchains = listOf(Blockchain.Adi),
)
}
}
@Test
fun `create passes no extra blockchains when batch is BB000053 but toggle is off`() = runTest {
// Arrange
val userWallet = mockk<UserWallet.Cold>(relaxed = true) {
every { walletId } returns userWalletId
every { scanResponse.card.batchId } returns "BB000053"
}
every { userWalletsListRepository.userWallets } returns MutableStateFlow(listOf(userWallet))
every {
featureTogglesManager.isFeatureEnabled(FeatureToggles.AND_15402_ADI_MAIN_SCREEN_DEFAULT_ENABLED)
} returns false
val accounts = AccountList.empty(userWallet.walletId).accounts
.filterIsInstance<Account.CryptoPortfolio>()
val defaultResponse = UserTokensResponse(
group = UserTokensResponse.GroupType.NETWORK,
sort = UserTokensResponse.SortType.BALANCE,
tokens = emptyList(),
)
every {
userTokensResponseFactory.createDefaultResponse(
userWallet = userWallet,
networkFactory = networkFactory,
accountId = accounts.first().accountId,
extraBlockchains = emptyList(),
)
} returns defaultResponse
every { cryptoPortfolioConverter.convertListBack(accounts) } returns emptyList()
// Act
factory.create(userWalletId, null)
// Assert
coVerifyOrder {
userTokensResponseFactory.createDefaultResponse(
userWallet = userWallet,
networkFactory = networkFactory,
accountId = accounts.first().accountId,
extraBlockchains = emptyList(),
)
}
}
@Test
fun `create passes no extra blockchains when batch is not BB000053 even if toggle is on`() = runTest {
// Arrange
val userWallet = mockk<UserWallet.Cold>(relaxed = true) {
every { walletId } returns userWalletId
every { scanResponse.card.batchId } returns "AC000001"
}
every { userWalletsListRepository.userWallets } returns MutableStateFlow(listOf(userWallet))
every {
featureTogglesManager.isFeatureEnabled(FeatureToggles.AND_15402_ADI_MAIN_SCREEN_DEFAULT_ENABLED)
} returns true
val accounts = AccountList.empty(userWallet.walletId).accounts
.filterIsInstance<Account.CryptoPortfolio>()
val defaultResponse = UserTokensResponse(
group = UserTokensResponse.GroupType.NETWORK,
sort = UserTokensResponse.SortType.BALANCE,
tokens = emptyList(),
)
every {
userTokensResponseFactory.createDefaultResponse(
userWallet = userWallet,
networkFactory = networkFactory,
accountId = accounts.first().accountId,
extraBlockchains = emptyList(),
)
} returns defaultResponse
every { cryptoPortfolioConverter.convertListBack(accounts) } returns emptyList()
// Act
factory.create(userWalletId, null)
// Assert
coVerifyOrder {
userTokensResponseFactory.createDefaultResponse(
userWallet = userWallet,
networkFactory = networkFactory,
accountId = accounts.first().accountId,
extraBlockchains = emptyList(),
)
}
}
@Test
fun `create returns response with assigned tokens`() = runTest {
// Arrange

View file

@ -0,0 +1,20 @@
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.data.appsflyer"
}
dependencies {
implementation(projects.core.datasource)
implementation(projects.domain.appsflyer)
implementation(deps.hilt.android)
kapt(deps.hilt.kapt)
}

View file

@ -0,0 +1,20 @@
package com.tangem.data.appsflyer
import com.tangem.datasource.local.appsflyer.AppsFlyerStore
import com.tangem.domain.appsflyer.AppsFlyerDeeplinkSource
import com.tangem.domain.appsflyer.repository.AppsFlyerRepository
import javax.inject.Inject
import com.tangem.datasource.local.appsflyer.AppsFlyerDeeplinkSource as StoreDeeplinkSource
internal class DefaultAppsFlyerRepository @Inject constructor(
private val appsFlyerStore: AppsFlyerStore,
) : AppsFlyerRepository {
override suspend fun clearDeeplink(source: AppsFlyerDeeplinkSource) {
appsFlyerStore.clearDeeplink(source.toStoreSource())
}
private fun AppsFlyerDeeplinkSource.toStoreSource() = when (this) {
AppsFlyerDeeplinkSource.TangemPayHotWalletOnboarding -> StoreDeeplinkSource.TangemPayHotWalletOnboarding
}
}

View file

@ -0,0 +1,30 @@
package com.tangem.data.appsflyer.di
import com.tangem.data.appsflyer.DefaultAppsFlyerRepository
import com.tangem.domain.appsflyer.repository.AppsFlyerRepository
import com.tangem.domain.appsflyer.usecase.ClearAppsFlyerDeeplinkUseCase
import dagger.Binds
import dagger.Module
import dagger.Provides
import dagger.hilt.InstallIn
import dagger.hilt.components.SingletonComponent
import javax.inject.Singleton
@Module
@InstallIn(SingletonComponent::class)
internal interface AppsFlyerDataModule {
@Binds
@Singleton
fun bindAppsFlyerRepository(repository: DefaultAppsFlyerRepository): AppsFlyerRepository
companion object {
@Provides
fun provideClearAppsFlyerDeeplinkUseCase(
appsFlyerRepository: AppsFlyerRepository,
): ClearAppsFlyerDeeplinkUseCase {
return ClearAppsFlyerDeeplinkUseCase(appsFlyerRepository)
}
}
}

View file

@ -9,6 +9,7 @@ import com.tangem.data.card.sdk.CardSdkProvider
import com.tangem.domain.card.models.TwinKey
import com.tangem.domain.card.repository.CardSdkConfigRepository
import com.tangem.domain.models.scan.ProductType
import com.tangem.domain.models.wallet.UserWalletId
/**
* Implementation of repository for managing of CardSDK config
@ -61,8 +62,13 @@ internal class DefaultCardSdkConfigRepository(
}
}
override fun getCommonSigner(cardId: String?, twinKey: TwinKey?): TransactionSigner {
return transactionSignerFactory.createTransactionSigner(cardId = cardId, sdk = sdk, twinKey = twinKey)
override fun getCommonSigner(cardId: String?, twinKey: TwinKey?, userWalletId: UserWalletId): TransactionSigner {
return transactionSignerFactory.createTransactionSigner(
cardId = cardId,
sdk = sdk,
twinKey = twinKey,
userWalletId = userWalletId,
)
}
override fun isLinkedTerminal() = sdk.config.linkedTerminal

View file

@ -3,11 +3,17 @@ package com.tangem.data.card
import com.tangem.TangemSdk
import com.tangem.blockchain.common.TransactionSigner
import com.tangem.domain.card.models.TwinKey
import com.tangem.domain.models.wallet.UserWalletId
/**
[REDACTED_AUTHOR]
*/
interface TransactionSignerFactory {
fun createTransactionSigner(cardId: String?, sdk: TangemSdk, twinKey: TwinKey?): TransactionSigner
fun createTransactionSigner(
cardId: String?,
sdk: TangemSdk,
twinKey: TwinKey?,
userWalletId: UserWalletId,
): TransactionSigner
}

View file

@ -1,5 +1,6 @@
package com.tangem.data.common.currency
import com.tangem.blockchain.common.Blockchain
import com.tangem.blockchainsdk.utils.toCoinId
import com.tangem.blockchainsdk.utils.toNetworkId
import com.tangem.data.common.network.NetworkFactory
@ -54,9 +55,14 @@ class UserTokensResponseFactory @Inject constructor() {
userWallet: UserWallet?,
networkFactory: NetworkFactory,
accountId: AccountId?,
extraBlockchains: List<Blockchain> = emptyList(),
): UserTokensResponse {
val tokens = if (userWallet != null) {
getDefaultWalletBlockchains(userWallet = userWallet, demoConfig = DemoConfig)
getDefaultWalletBlockchains(
userWallet = userWallet,
demoConfig = DemoConfig,
extraBlockchains = extraBlockchains,
)
.map { blockchain ->
val derivationPath = networkFactory.createDerivationPath(
blockchain = blockchain,

View file

@ -375,6 +375,7 @@ class NetworkFactory @Inject constructor(
Blockchain.Linea, Blockchain.LineaTestnet,
Blockchain.ArbitrumNova,
Blockchain.Plasma, Blockchain.PlasmaTestnet,
Blockchain.Adi, Blockchain.AdiTestnet,
Blockchain.SeiEvm, Blockchain.SeiEvmTestnet,
Blockchain.Monad, Blockchain.MonadTestnet,
-> Network.TransactionExtrasType.NONE

View file

@ -8,10 +8,16 @@ import com.tangem.domain.models.wallet.UserWallet
/**
* Returns the default blockchains for the multi-currency wallet.
*
* @param userWallet The user's wallet, which can be either a cold or hot wallet.
* @param demoConfig Configuration for demo cards, which may specify different default blockchains.
* @param userWallet The user's wallet, which can be either a cold or hot wallet.
* @param demoConfig Configuration for demo cards, which may specify different default blockchains.
* @param extraBlockchains Additional blockchains appended on top of the standard defaults for non-demo cold wallets
* (e.g. batch- or promo-specific entries resolved by the caller).
*/
fun getDefaultWalletBlockchains(userWallet: UserWallet, demoConfig: DemoConfig): Collection<Blockchain> {
fun getDefaultWalletBlockchains(
userWallet: UserWallet,
demoConfig: DemoConfig,
extraBlockchains: List<Blockchain> = emptyList(),
): Collection<Blockchain> {
return when (userWallet) {
is UserWallet.Cold -> {
val card = userWallet.scanResponse.card
@ -19,7 +25,7 @@ fun getDefaultWalletBlockchains(userWallet: UserWallet, demoConfig: DemoConfig):
var blockchainsInternal = if (demoConfig.isDemoCardId(card.cardId)) {
demoConfig.getDemoBlockchains(card.cardId)
} else {
listOf(Blockchain.Bitcoin, Blockchain.Ethereum)
listOf(Blockchain.Bitcoin, Blockchain.Ethereum) + extraBlockchains
}
if (card.isTestCard) {

View file

@ -26,6 +26,7 @@ dependencies {
// region Project - Domain
implementation(projects.domain.account)
implementation(projects.domain.common)
implementation(projects.domain.dynamicAddresses)
implementation(projects.domain.dynamicAddresses.models)
implementation(projects.domain.models)

View file

@ -1,5 +1,7 @@
package com.tangem.data.dynamicaddresses
import com.tangem.domain.common.wallets.UserWalletsListRepository
import com.tangem.domain.common.wallets.getSyncOrNull
import com.tangem.domain.dynamicaddresses.DynamicAddressesFeatureToggles
import com.tangem.domain.dynamicaddresses.DynamicAddressesSupportedBlockchains
import com.tangem.domain.dynamicaddresses.GetDerivedXpubUseCase
@ -7,6 +9,7 @@ import com.tangem.domain.dynamicaddresses.model.DynamicAddressesStatus
import com.tangem.domain.dynamicaddresses.repository.DynamicAddressesRepository
import com.tangem.domain.models.network.Network
import com.tangem.domain.models.wallet.UserWalletId
import com.tangem.domain.models.wallet.isMultiCurrency
import com.tangem.utils.logging.TangemLogger
import kotlinx.coroutines.flow.firstOrNull
import javax.inject.Inject
@ -21,11 +24,22 @@ class DynamicAddressesInitializer @Inject constructor(
private val dynamicAddressesRepository: DynamicAddressesRepository,
private val dynamicAddressesFeatureToggles: DynamicAddressesFeatureToggles,
private val getDerivedXpubUseCase: GetDerivedXpubUseCase,
private val userWalletsListRepository: UserWalletsListRepository,
) {
suspend fun getXpubs(userWalletId: UserWalletId, networks: Set<Network>): Map<Network, String> {
if (!dynamicAddressesFeatureToggles.isDynamicAddressesEnabled) return emptyMap()
/*
* Dynamic addresses rely on the server-side wallet accounts list, which is populated only for
* multi-currency wallets. Single-currency wallets (Note, s2c, etc.) never populate it, so
* DynamicAddressesRepository.getStatus() backed by WalletAccountsFetcher.get() would never
* emit and firstOrNull() below would suspend forever, hanging the whole balance fetch and leaving
* the currency stuck in Loading. Skip such wallets entirely. ([REDACTED_TASK_KEY])
*/
val userWallet = userWalletsListRepository.getSyncOrNull(userWalletId)
if (userWallet == null || !userWallet.isMultiCurrency) return emptyMap()
val result = mutableMapOf<Network, String>()
for (network in networks) {
if (!DynamicAddressesSupportedBlockchains.isSupportedByNetworkId(network.rawId)) continue

View file

@ -29,9 +29,7 @@ import com.tangem.datasource.local.onramp.currencies.OnrampCurrenciesStore
import com.tangem.datasource.local.onramp.pairs.OnrampPairsStore
import com.tangem.datasource.local.onramp.paymentmethods.OnrampPaymentMethodsStore
import com.tangem.datasource.local.onramp.quotes.OnrampQuotesStore
import com.tangem.datasource.local.onramp.sepa.OnrampCurrentCountryByIPStore
import com.tangem.datasource.local.onramp.sepa.OnrampSepaAvailabilityStore
import com.tangem.datasource.local.onramp.sepa.OnrampSepaAvailabilityStoreKey
import com.tangem.datasource.local.onramp.country.OnrampCurrentCountryByIPStore
import com.tangem.datasource.local.preferences.AppPreferencesStore
import com.tangem.datasource.local.preferences.PreferencesKeys
import com.tangem.datasource.local.preferences.utils.getObject
@ -67,7 +65,6 @@ internal class DefaultOnrampRepository(
private val dispatchers: CoroutineDispatcherProvider,
private val appPreferencesStore: AppPreferencesStore,
private val paymentMethodsStore: OnrampPaymentMethodsStore,
private val onrampSepaAvailabilityStore: OnrampSepaAvailabilityStore,
private val onrampCurrentCountryByIPStore: OnrampCurrentCountryByIPStore,
private val pairsStore: OnrampPairsStore,
private val quotesStore: OnrampQuotesStore,
@ -281,62 +278,6 @@ internal class DefaultOnrampRepository(
storeOnrampPairs(pairs = onrampPairs.await(), providers = providers.await())
}
override suspend fun hasSepaMethod(
userWallet: UserWallet,
country: OnrampCountry,
cryptoCurrency: CryptoCurrency,
): Boolean {
return withContext(dispatchers.io) {
val key = OnrampSepaAvailabilityStoreKey(
userWallet = userWallet,
country = country,
cryptoCurrency = cryptoCurrency,
)
val isCachedValue = onrampSepaAvailabilityStore.getSyncOrNull(key)
if (isCachedValue != null) {
return@withContext isCachedValue
}
val onrampPairs =
safeApiCall(
call = {
onrampApi.getPairs(
userWalletId = userWallet.walletId.stringValue,
refCode = ExpressUtils.getRefCode(
userWallet = userWallet,
appPreferencesStore = appPreferencesStore,
),
body = OnrampPairsRequest(
fromCurrencyCode = EUR_CURRENCY_CODE,
countryCode = country.code,
to = listOf(
OnrampDestinationDTO(
contractAddress = cryptoCurrency.getContractAddress(),
network = cryptoCurrency.network.rawId,
),
),
),
).bind()
},
onError = { error ->
TangemLogger.w("Unable to fetch onramp pairs", error)
throw error
},
)
val hasSepaMethod = onrampPairs
.flatMap { it.providers }
.flatMap { it.paymentMethods }
.any { it == SEPA_METHOD_ID }
onrampSepaAvailabilityStore.store(key, hasSepaMethod)
hasSepaMethod
}
}
override suspend fun fetchQuotes(userWallet: UserWallet, cryptoCurrency: CryptoCurrency, amount: Amount) =
withContext(dispatchers.io) {
val pairs = requireNotNull(pairsStore.getSyncOrNull(PAIRS_KEY)) {
@ -632,8 +573,5 @@ internal class DefaultOnrampRepository(
const val PROVIDER_THEME_LIGHT = "light"
const val REDIRECT_URL = "https://tangem.com/onramp"
const val SEPA_METHOD_ID = "sepa"
const val EUR_CURRENCY_CODE = "EUR"
}
}

View file

@ -24,8 +24,7 @@ import com.tangem.datasource.local.onramp.currencies.OnrampCurrenciesStore
import com.tangem.datasource.local.onramp.pairs.OnrampPairsStore
import com.tangem.datasource.local.onramp.paymentmethods.OnrampPaymentMethodsStore
import com.tangem.datasource.local.onramp.quotes.OnrampQuotesStore
import com.tangem.datasource.local.onramp.sepa.OnrampCurrentCountryByIPStore
import com.tangem.datasource.local.onramp.sepa.OnrampSepaAvailabilityStore
import com.tangem.datasource.local.onramp.country.OnrampCurrentCountryByIPStore
import com.tangem.datasource.local.preferences.AppPreferencesStore
import com.tangem.domain.common.wallets.UserWalletsListRepository
import com.tangem.utils.coroutines.AppCoroutineScope
@ -56,7 +55,6 @@ internal object OnrampDataModule {
currenciesStore: OnrampCurrenciesStore,
walletManagersFacade: WalletManagersFacade,
dataSignatureVerifier: DataSignatureVerifier,
onrampSepaAvailabilityStore: OnrampSepaAvailabilityStore,
onrampCurrentCountryByIPStore: OnrampCurrentCountryByIPStore,
@NetworkMoshi moshi: Moshi,
): OnrampRepository {
@ -66,7 +64,6 @@ internal object OnrampDataModule {
dispatchers = dispatchers,
appPreferencesStore = appPreferencesStore,
paymentMethodsStore = paymentMethodsStore,
onrampSepaAvailabilityStore = onrampSepaAvailabilityStore,
onrampCurrentCountryByIPStore = onrampCurrentCountryByIPStore,
pairsStore = pairsStore,
quotesStore = quotesStore,

View file

@ -163,6 +163,7 @@ public val Blockchain.mercuryoNetwork: String?
Blockchain.Linea, Blockchain.LineaTestnet -> null
Blockchain.ArbitrumNova -> null
Blockchain.Plasma, Blockchain.PlasmaTestnet -> null
Blockchain.Adi, Blockchain.AdiTestnet -> null
Blockchain.SeiEvm, Blockchain.SeiEvmTestnet -> null
Blockchain.Monad, Blockchain.MonadTestnet -> null
}

View file

@ -1,203 +0,0 @@
package com.tangem.data.promo
import com.tangem.data.promo.converters.PromoBannerConverter
import com.tangem.data.promo.converters.StoryContentResponseConverter
import com.tangem.datasource.api.common.response.getOrThrow
import com.tangem.datasource.api.tangemTech.TangemTechApi
import com.tangem.datasource.local.preferences.AppPreferencesStore
import com.tangem.datasource.local.preferences.PreferencesKeys
import com.tangem.datasource.local.preferences.PreferencesKeys.getShouldShowStoriesKey
import com.tangem.datasource.local.preferences.utils.get
import com.tangem.datasource.local.preferences.utils.getSyncOrDefault
import com.tangem.datasource.local.preferences.utils.store
import com.tangem.datasource.local.promo.PromoBannerStore
import com.tangem.datasource.local.promo.PromoStoriesStore
import com.tangem.domain.models.wallet.UserWalletId
import com.tangem.domain.promo.PromoRepository
import com.tangem.domain.promo.models.PromoBanner
import com.tangem.domain.promo.models.PromoId
import com.tangem.domain.promo.models.StoryContent
import com.tangem.feature.referral.domain.ReferralRepository
import com.tangem.utils.coroutines.CoroutineDispatcherProvider
import com.tangem.utils.coroutines.runCatching
import com.tangem.utils.coroutines.runSuspendCatching
import kotlinx.coroutines.flow.*
import kotlinx.coroutines.withContext
import kotlinx.coroutines.withTimeoutOrNull
internal class DefaultPromoRepository(
private val tangemApi: TangemTechApi,
private val appPreferencesStore: AppPreferencesStore,
private val promoStoriesStore: PromoStoriesStore,
private val promoBannerStore: PromoBannerStore,
private val dispatchers: CoroutineDispatcherProvider,
private val referralRepository: ReferralRepository,
) : PromoRepository {
private val storyContentConverter = StoryContentResponseConverter()
private val promoBannerConverter = PromoBannerConverter()
override fun isReadyToShowWalletPromo(userWalletId: UserWalletId, promoId: PromoId): Flow<Boolean> {
return appPreferencesStore.get(
key = PreferencesKeys.getShouldShowPromoKey(promoId = promoId.name),
default = true,
)
.distinctUntilChanged()
.map { shouldShow ->
when (promoId) {
PromoId.Referral -> runSuspendCatching {
!referralRepository.isReferralParticipant(userWalletId) && shouldShow
}.getOrDefault(false)
PromoId.Sepa -> {
val isActive = getSepaPromoBanner()?.isActive == true
isActive && shouldShow
}
PromoId.VisaPresale -> {
val isActive = getVisaPromoBanner()?.isActive == true
isActive && shouldShow
}
PromoId.BlackFriday -> {
val isActive = getBlackFridayPromoBanner()?.isActive == true
isActive && shouldShow
}
PromoId.OnePlusOne -> {
val isActive = getOnePlusOnePromoBanner()?.isActive == true
isActive && shouldShow
}
PromoId.YieldPromo -> {
val isActive = getYieldPromoBanner(userWalletId)?.isActive == true
isActive && shouldShow
}
}
}
}
override fun isReadyToShowTokenPromo(promoId: PromoId): Flow<Boolean> {
return when (promoId) {
PromoId.Referral -> flowOf(false)
PromoId.Sepa -> flowOf(false)
PromoId.VisaPresale -> flowOf(false)
PromoId.BlackFriday -> flowOf(false)
PromoId.OnePlusOne -> flowOf(false)
PromoId.YieldPromo -> flowOf(false)
}
}
override suspend fun setNeverToShowWalletPromo(promoId: PromoId) {
appPreferencesStore.store(PreferencesKeys.getShouldShowPromoKey(promoId = promoId.name), false)
}
override suspend fun setNeverToShowTokenPromo(promoId: PromoId) {
appPreferencesStore.store(PreferencesKeys.getShouldShowPromoKey(promoId = promoId.name), false)
}
override suspend fun isMoonpayPromoActive(): Boolean {
val banner = runCatching(dispatchers.io) {
val response = promoBannerStore.getSyncOrNull(MOONPAY_NAME) ?: run {
val apiResponse = tangemApi.getPromoBanner(MOONPAY_NAME).getOrThrow()
promoBannerStore.store(MOONPAY_NAME, apiResponse)
apiResponse
}
promoBannerConverter.convert(response)
}.getOrNull()
return banner?.isActive == true
}
override fun getStoryById(id: String): Flow<StoryContent?> = isReadyToShowStories(id).mapLatest {
getStoryByIdSync(id = id, refresh = false)
}
override suspend fun getStoryByIdSync(id: String, refresh: Boolean): StoryContent? = withContext(dispatchers.io) {
if (!isReadyToShowStoriesSync(id)) return@withContext null
val storedPromo = promoStoriesStore.getSyncOrNull(storyId = id)
// Get last stored promo by id if possible or get from network
val story = if (storedPromo == null && refresh) {
val storyContent = runSuspendCatching {
// Important to return
withTimeoutOrNull(STORIES_LOAD_DELAY) {
tangemApi.getStoryById(storyId = id).getOrThrow()
}
}.getOrNull()
if (storyContent != null) {
promoStoriesStore.store(id, storyContent)
}
storyContent
} else {
storedPromo
}
story?.let { storyContentConverter.convert(it) }
}
override fun isReadyToShowStories(storyId: String): Flow<Boolean> {
return appPreferencesStore.get(getShouldShowStoriesKey(storyId), true)
}
override suspend fun isReadyToShowStoriesSync(storyId: String): Boolean {
return appPreferencesStore.getSyncOrDefault(getShouldShowStoriesKey(storyId), true)
}
override suspend fun setNeverToShowStories(storyId: String) {
appPreferencesStore.store(
key = getShouldShowStoriesKey(storyId),
value = false,
)
}
private suspend fun getSepaPromoBanner(): PromoBanner? {
return runCatching(dispatchers.io) {
promoBannerConverter.convert(
tangemApi.getPromoBanner(SEPA_NAME).getOrThrow(),
)
}.getOrNull()
}
private suspend fun getVisaPromoBanner(): PromoBanner? {
return runCatching(dispatchers.io) {
promoBannerConverter.convert(
tangemApi.getPromoBanner(VISA_NAME).getOrThrow(),
)
}.getOrNull()
}
private suspend fun getBlackFridayPromoBanner(): PromoBanner? {
return runCatching(dispatchers.io) {
promoBannerConverter.convert(
tangemApi.getPromoBanner(BLACK_FRIDAY_NAME).getOrThrow(),
)
}.getOrNull()
}
private suspend fun getOnePlusOnePromoBanner(): PromoBanner? {
return runCatching(dispatchers.io) {
promoBannerConverter.convert(
tangemApi.getPromoBanner(ONE_PLUS_ONE_NAME).getOrThrow(),
)
}.getOrNull()
}
private suspend fun getYieldPromoBanner(userWalletId: UserWalletId): PromoBanner? {
return runCatching(dispatchers.io) {
val response = tangemApi.getPromoBannersV2(userWalletId.stringValue).getOrThrow()
val yieldPromotion = response.promotions.find { it.name == YIELD_PROMO_NAME }
?: return@runCatching null
promoBannerConverter.convert(yieldPromotion)
}.getOrNull()
}
private companion object {
const val SEPA_NAME = "sepa"
const val VISA_NAME = "visa-waitlist"
const val BLACK_FRIDAY_NAME = "black-friday"
const val MOONPAY_NAME = "moonpay"
const val ONE_PLUS_ONE_NAME = "one-plus-one"
const val YIELD_PROMO_NAME = "promo-yield"
const val STORIES_LOAD_DELAY = 1000L
}
}

View file

@ -1,24 +0,0 @@
package com.tangem.data.promo.converters
import com.tangem.datasource.api.promotion.models.PromoBannerResponse
import com.tangem.domain.promo.models.PromoBanner
import com.tangem.utils.converter.Converter
import org.joda.time.DateTime
class PromoBannerConverter : Converter<PromoBannerResponse, PromoBanner?> {
override fun convert(value: PromoBannerResponse): PromoBanner? {
val bannerState = value.bannerState ?: return null
return PromoBanner(
name = value.name,
bannerState = PromoBanner.BannerState(
status = bannerState.status,
link = bannerState.link,
timeline = PromoBanner.Timeline(
start = DateTime.parse(bannerState.timeline.start),
end = DateTime.parse(bannerState.timeline.end),
),
),
)
}
}

View file

@ -0,0 +1,39 @@
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.data.pushnotificationpreferences"
}
dependencies {
/** Domain */
implementation(projects.domain.pushNotificationPreferences)
implementation(projects.domain.models)
/** Core */
implementation(projects.core.datasource)
implementation(projects.core.utils)
/** Other */
implementation(deps.androidx.datastore)
implementation(deps.arrow.core)
implementation(deps.kotlin.coroutines)
/** DI */
implementation(deps.hilt.android)
kapt(deps.hilt.kapt)
/** Tests */
testImplementation(deps.test.junit)
testImplementation(deps.test.coroutine)
testImplementation(deps.test.truth)
testImplementation(deps.test.mockk)
testImplementation(deps.test.turbine)
testImplementation(deps.moshi)
testImplementation(deps.moshi.kotlin)
}

View file

@ -0,0 +1,110 @@
package com.tangem.data.pushnotificationpreferences
import arrow.core.Either
import com.tangem.datasource.api.tangemTech.TangemTechApi
import com.tangem.datasource.local.datastore.RuntimeSharedStore
import com.tangem.datasource.local.preferences.AppPreferencesStore
import com.tangem.datasource.local.preferences.PreferencesKeys
import com.tangem.datasource.local.preferences.utils.getObjectMapSync
import com.tangem.domain.models.wallet.UserWalletId
import com.tangem.domain.pushnotificationpreferences.models.PushNotificationCategory
import com.tangem.domain.pushnotificationpreferences.models.PushNotificationPreference
import com.tangem.domain.pushnotificationpreferences.models.WalletPushNotificationPreferences
import com.tangem.domain.pushnotificationpreferences.repository.WalletPushNotificationPreferencesRepository
import com.tangem.utils.coroutines.CoroutineDispatcherProvider
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.distinctUntilChanged
import kotlinx.coroutines.flow.filterNotNull
import kotlinx.coroutines.flow.map
import kotlinx.coroutines.flow.onStart
import kotlinx.coroutines.withContext
/**
* In-memory cache implementation of [WalletPushNotificationPreferencesRepository].
*
* Mock-mode (current): defaults are computed locally and writes are kept in-memory only.
* Real-mode (when Variant C BE is ready): replace TODO blocks with [TangemTechApi] calls.
*
* Defaults for existing users (until BE migration runs): TX read from
* [PreferencesKeys.NOTIFICATIONS_ENABLED_STATES_KEY] (default true), Offers&Updates = true, Price Alerts = false,
* isVisible = true for all three.
*/
internal class DefaultWalletPushNotificationPreferencesRepository(
private val appPreferencesStore: AppPreferencesStore,
@Suppress("unused") private val tangemTechApi: TangemTechApi,
private val cache: RuntimeSharedStore<Map<String, WalletPushNotificationPreferences>>,
private val dispatchers: CoroutineDispatcherProvider,
) : WalletPushNotificationPreferencesRepository {
override suspend fun preload(userWalletId: UserWalletId) {
if (cache.getSyncOrNull()?.containsKey(userWalletId.stringValue) == true) return
val preferences = withContext(dispatchers.io) {
// TODO: uncomment when api is ready
// val response = tangemTechApi.getPushNotificationPreferences(userWalletId.stringValue).getOrThrow()
// PushNotificationPreferencesConverter.convert(response)
loadDefaults(userWalletId)
}
cache.update(default = emptyMap()) { current ->
if (current.containsKey(userWalletId.stringValue)) {
current
} else {
current + (userWalletId.stringValue to preferences)
}
}
}
override fun observePreferences(userWalletId: UserWalletId): Flow<WalletPushNotificationPreferences> = cache.get()
.onStart { preload(userWalletId) }
.map { it[userWalletId.stringValue] }
.filterNotNull()
.distinctUntilChanged()
override suspend fun updatePreference(
userWalletId: UserWalletId,
category: PushNotificationCategory,
isEnabled: Boolean,
): Either<Throwable, Unit> = Either.catch {
val current = cache.getSyncOrNull()?.get(userWalletId.stringValue) ?: loadDefaults(userWalletId)
val updated = applyCategory(current, category, isEnabled)
withContext(dispatchers.io) {
// TODO: uncomment when api is ready
// tangemTechApi.updatePushNotificationPreferences(
// walletId = userWalletId.stringValue,
// body = PushNotificationPreferencesBody(
// areTransactionAlertsEnabled = updated.transactionAlerts.isEnabled,
// areOffersUpdatesEnabled = updated.offersUpdates.isEnabled,
// arePriceAlertsEnabled = updated.priceAlerts.isEnabled,
// ),
// ).getOrThrow()
}
cache.update(default = emptyMap()) { it + (userWalletId.stringValue to updated) }
}
private fun applyCategory(
current: WalletPushNotificationPreferences,
category: PushNotificationCategory,
isEnabled: Boolean,
): WalletPushNotificationPreferences = when (category) {
PushNotificationCategory.TransactionAlerts -> current.copy(
transactionAlerts = current.transactionAlerts.copy(isEnabled = isEnabled),
)
PushNotificationCategory.OffersUpdates -> current.copy(
offersUpdates = current.offersUpdates.copy(isEnabled = isEnabled),
)
PushNotificationCategory.PriceAlerts -> current.copy(
priceAlerts = current.priceAlerts.copy(isEnabled = isEnabled),
)
}
// TODO remove when api is ready, use api methods to load real settings
private suspend fun loadDefaults(userWalletId: UserWalletId): WalletPushNotificationPreferences {
val areTransactionAlertsEnabled = appPreferencesStore
.getObjectMapSync<Boolean>(PreferencesKeys.NOTIFICATIONS_ENABLED_STATES_KEY)[userWalletId.stringValue] !=
false
return WalletPushNotificationPreferences(
transactionAlerts = PushNotificationPreference(isEnabled = areTransactionAlertsEnabled, isVisible = true),
offersUpdates = PushNotificationPreference(isEnabled = true, isVisible = true),
priceAlerts = PushNotificationPreference(isEnabled = false, isVisible = true),
)
}
}

View file

@ -0,0 +1,21 @@
package com.tangem.data.pushnotificationpreferences.converters
import com.tangem.datasource.api.tangemTech.models.PushNotificationPreferenceState
import com.tangem.datasource.api.tangemTech.models.PushNotificationPreferencesResponse
import com.tangem.domain.pushnotificationpreferences.models.PushNotificationPreference
import com.tangem.domain.pushnotificationpreferences.models.WalletPushNotificationPreferences
import com.tangem.utils.converter.Converter
internal object PushNotificationPreferencesConverter :
Converter<PushNotificationPreferencesResponse, WalletPushNotificationPreferences> {
override fun convert(value: PushNotificationPreferencesResponse): WalletPushNotificationPreferences =
WalletPushNotificationPreferences(
transactionAlerts = value.transactionAlerts.toDomain(),
offersUpdates = value.offersUpdates.toDomain(),
priceAlerts = value.priceAlerts.toDomain(),
)
private fun PushNotificationPreferenceState.toDomain(): PushNotificationPreference =
PushNotificationPreference(isEnabled = isEnabled, isVisible = isVisible)
}

View file

@ -0,0 +1,31 @@
package com.tangem.data.pushnotificationpreferences.di
import com.tangem.data.pushnotificationpreferences.DefaultWalletPushNotificationPreferencesRepository
import com.tangem.datasource.api.tangemTech.TangemTechApi
import com.tangem.datasource.local.datastore.RuntimeSharedStore
import com.tangem.datasource.local.preferences.AppPreferencesStore
import com.tangem.domain.pushnotificationpreferences.repository.WalletPushNotificationPreferencesRepository
import com.tangem.utils.coroutines.CoroutineDispatcherProvider
import dagger.Module
import dagger.Provides
import dagger.hilt.InstallIn
import dagger.hilt.components.SingletonComponent
import javax.inject.Singleton
@Module
@InstallIn(SingletonComponent::class)
internal object PushNotificationPreferencesModule {
@Singleton
@Provides
fun providesWalletPushNotificationPreferencesRepository(
appPreferencesStore: AppPreferencesStore,
tangemTechApi: TangemTechApi,
dispatchers: CoroutineDispatcherProvider,
): WalletPushNotificationPreferencesRepository = DefaultWalletPushNotificationPreferencesRepository(
appPreferencesStore = appPreferencesStore,
tangemTechApi = tangemTechApi,
cache = RuntimeSharedStore(),
dispatchers = dispatchers,
)
}

View file

@ -0,0 +1,141 @@
package com.tangem.data.pushnotificationpreferences
import androidx.datastore.core.DataStore
import androidx.datastore.preferences.core.Preferences
import androidx.datastore.preferences.core.emptyPreferences
import app.cash.turbine.test
import arrow.core.Either
import com.google.common.truth.Truth.assertThat
import com.squareup.moshi.Moshi
import com.tangem.datasource.api.tangemTech.TangemTechApi
import com.tangem.datasource.local.datastore.RuntimeSharedStore
import com.tangem.datasource.local.preferences.AppPreferencesStore
import com.tangem.domain.models.wallet.UserWalletId
import com.tangem.domain.pushnotificationpreferences.models.PushNotificationCategory
import com.tangem.domain.pushnotificationpreferences.models.PushNotificationPreference
import com.tangem.domain.pushnotificationpreferences.models.WalletPushNotificationPreferences
import com.tangem.utils.coroutines.TestingCoroutineDispatcherProvider
import io.mockk.coEvery
import io.mockk.mockk
import kotlinx.coroutines.flow.flowOf
import kotlinx.coroutines.test.runTest
import org.junit.Test
class DefaultWalletPushNotificationPreferencesRepositoryTest {
private val tangemTechApi: TangemTechApi = mockk()
private val preferencesDataStore: DataStore<Preferences> = mockk()
private val appPreferencesStore = AppPreferencesStore(
moshi = Moshi.Builder().build(),
dispatchers = TestingCoroutineDispatcherProvider(),
preferencesDataStore = preferencesDataStore,
)
private val userWalletId = UserWalletId(stringValue = "0011223344556677")
private val otherWalletId = UserWalletId(stringValue = "ffeeddccbbaa9988")
private val repository = DefaultWalletPushNotificationPreferencesRepository(
appPreferencesStore = appPreferencesStore,
tangemTechApi = tangemTechApi,
cache = RuntimeSharedStore(),
dispatchers = TestingCoroutineDispatcherProvider(),
)
@Test
fun `GIVEN no prior state WHEN preload THEN cache contains defaults`() = runTest {
coEvery { preferencesDataStore.data } returns flowOf(emptyPreferences())
repository.preload(userWalletId)
repository.observePreferences(userWalletId).test {
assertThat(awaitItem()).isEqualTo(defaults(transactionAlertsEnabled = true))
}
}
@Test
fun `GIVEN preload already done WHEN preload called again THEN no-op`() = runTest {
coEvery { preferencesDataStore.data } returns flowOf(emptyPreferences())
repository.preload(userWalletId)
repository.updatePreference(userWalletId, PushNotificationCategory.OffersUpdates, isEnabled = false)
repository.preload(userWalletId)
repository.observePreferences(userWalletId).test {
val item = awaitItem()
assertThat(item.offersUpdates.isEnabled).isFalse()
}
}
@Test
fun `GIVEN cache miss WHEN updatePreference THEN loads defaults and applies update`() = runTest {
coEvery { preferencesDataStore.data } returns flowOf(emptyPreferences())
val result = repository.updatePreference(
userWalletId = userWalletId,
category = PushNotificationCategory.PriceAlerts,
isEnabled = true,
)
assertThat(result).isInstanceOf(Either.Right::class.java)
repository.observePreferences(userWalletId).test {
val item = awaitItem()
assertThat(item.priceAlerts.isEnabled).isTrue()
assertThat(item.offersUpdates.isEnabled).isTrue()
assertThat(item.transactionAlerts.isEnabled).isTrue()
}
}
@Test
fun `GIVEN preloaded state WHEN updatePreference for each category THEN updates only that category`() = runTest {
coEvery { preferencesDataStore.data } returns flowOf(emptyPreferences())
repository.preload(userWalletId)
repository.updatePreference(userWalletId, PushNotificationCategory.TransactionAlerts, isEnabled = false)
repository.updatePreference(userWalletId, PushNotificationCategory.OffersUpdates, isEnabled = false)
repository.updatePreference(userWalletId, PushNotificationCategory.PriceAlerts, isEnabled = true)
repository.observePreferences(userWalletId).test {
val item = awaitItem()
assertThat(item.transactionAlerts.isEnabled).isFalse()
assertThat(item.offersUpdates.isEnabled).isFalse()
assertThat(item.priceAlerts.isEnabled).isTrue()
}
}
@Test
fun `GIVEN no subscription yet WHEN observePreferences subscribed THEN triggers preload and emits defaults`() =
runTest {
coEvery { preferencesDataStore.data } returns flowOf(emptyPreferences())
repository.observePreferences(userWalletId).test {
val item = awaitItem()
assertThat(item).isEqualTo(defaults(transactionAlertsEnabled = true))
}
}
@Test
fun `GIVEN updates for different wallets WHEN observed independently THEN each wallet has its own state`() =
runTest {
coEvery { preferencesDataStore.data } returns flowOf(emptyPreferences())
repository.updatePreference(userWalletId, PushNotificationCategory.OffersUpdates, isEnabled = false)
repository.updatePreference(otherWalletId, PushNotificationCategory.PriceAlerts, isEnabled = true)
repository.observePreferences(userWalletId).test {
val item = awaitItem()
assertThat(item.offersUpdates.isEnabled).isFalse()
assertThat(item.priceAlerts.isEnabled).isFalse()
}
repository.observePreferences(otherWalletId).test {
val item = awaitItem()
assertThat(item.offersUpdates.isEnabled).isTrue()
assertThat(item.priceAlerts.isEnabled).isTrue()
}
}
private fun defaults(transactionAlertsEnabled: Boolean) = WalletPushNotificationPreferences(
transactionAlerts = PushNotificationPreference(isEnabled = transactionAlertsEnabled, isVisible = true),
offersUpdates = PushNotificationPreference(isEnabled = true, isVisible = true),
priceAlerts = PushNotificationPreference(isEnabled = false, isVisible = true),
)
}

View file

@ -7,28 +7,35 @@ import arrow.core.raise.either
import arrow.core.raise.ensure
import com.tangem.data.staking.converters.ethpool.*
import com.tangem.datasource.api.common.response.ApiResponse
import com.tangem.datasource.api.common.response.getOrThrow
import com.tangem.datasource.api.ethpool.P2PEthPoolApi
import com.tangem.datasource.api.ethpool.models.request.P2PEthPoolBroadcastRequest
import com.tangem.datasource.api.ethpool.models.request.P2PEthPoolTransactionRequest
import com.tangem.datasource.api.ethpool.models.response.P2PEthPoolResponse
import com.tangem.datasource.api.ethpool.models.response.P2PEthPoolTransactionResponse
import com.tangem.datasource.api.tangemTech.TangemTechApi
import com.tangem.datasource.local.token.P2PEthPoolVaultsStore
import com.tangem.datasource.local.token.P2PVaultLimitsStore
import com.tangem.domain.models.staking.P2PEthPoolStakingAccount
import com.tangem.domain.staking.model.P2PEthPoolIntegration
import com.tangem.domain.staking.model.StakingAvailability
import com.tangem.domain.staking.model.StakingIntegrationID
import com.tangem.domain.staking.model.StakingOption
import com.tangem.domain.staking.model.ethpool.P2PEthPoolBroadcastResult
import com.tangem.domain.staking.model.ethpool.P2PEthPoolNetwork
import com.tangem.domain.staking.model.ethpool.P2PEthPoolStakingConfig
import com.tangem.domain.staking.model.ethpool.P2PEthPoolUnsignedTx
import com.tangem.domain.staking.model.ethpool.P2PEthPoolVault
import com.tangem.domain.staking.model.ethpool.VaultLimitInfo
import com.tangem.domain.staking.model.stakekit.StakingError
import com.tangem.domain.staking.repositories.P2PEthPoolRepository
import com.tangem.domain.staking.model.StakingIntegrationID
import com.tangem.domain.staking.toggles.StakingFeatureToggles
import com.tangem.utils.coroutines.CoroutineDispatcherProvider
import com.tangem.utils.coroutines.runSuspendCatching
import com.tangem.utils.logging.TangemLogger
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.combine
import kotlinx.coroutines.flow.distinctUntilChanged
import kotlinx.coroutines.flow.map
import kotlinx.coroutines.withContext
/**
@ -37,6 +44,8 @@ import kotlinx.coroutines.withContext
internal class DefaultP2PEthPoolRepository(
private val p2pEthPoolApi: P2PEthPoolApi,
private val p2pEthPoolVaultsStore: P2PEthPoolVaultsStore,
private val p2pVaultLimitsStore: P2PVaultLimitsStore,
private val tangemTechApi: TangemTechApi,
private val dispatchers: CoroutineDispatcherProvider,
private val stakingFeatureToggles: StakingFeatureToggles,
) : P2PEthPoolRepository {
@ -81,7 +90,9 @@ internal class DefaultP2PEthPoolRepository(
override suspend fun getVaults(network: P2PEthPoolNetwork): Either<StakingError, List<P2PEthPoolVault>> = either {
withContext(dispatchers.io) {
handleApiResponse(p2pEthPoolApi.getVaults(network.value)) { result ->
result.vaults.map { vaultConverter.convert(it) }
result.vaults
.map { vaultConverter.convert(it) }
.filter { it.vaultAddress.lowercase() !in P2PEthPoolStakingConfig.TEST_VAULT_ADDRESSES }
}
}
}
@ -180,21 +191,32 @@ internal class DefaultP2PEthPoolRepository(
}
override fun getStakingAvailability(): Flow<StakingAvailability> {
return getVaultsFlow()
.distinctUntilChanged()
.map { vaults ->
if (vaults.isEmpty()) {
return@map StakingAvailability.TemporaryUnavailable
} else {
StakingAvailability.Available(StakingOption.P2PEthPool(vaults))
return combine(
getVaultsFlow().distinctUntilChanged(),
getVaultLimitsFlow().distinctUntilChanged(),
) { vaults, limits ->
when {
vaults.isEmpty() -> StakingAvailability.TemporaryUnavailable
limits == null -> StakingAvailability.TemporaryUnavailable
else -> {
val integration = P2PEthPoolIntegration(StakingIntegrationID.P2PEthPool, vaults, limits)
if (integration.areAllTargetsFull) {
StakingAvailability.Full(StakingOption.P2PEthPool(vaults))
} else {
StakingAvailability.Available(StakingOption.P2PEthPool(vaults))
}
}
}
}.distinctUntilChanged()
}
override suspend fun getStakingAvailabilitySync(): StakingAvailability {
val vaults = getVaultsSync()
return if (vaults.isEmpty()) {
StakingAvailability.TemporaryUnavailable
if (vaults.isEmpty()) return StakingAvailability.TemporaryUnavailable
val limits = getVaultLimitsSyncOrNull() ?: return StakingAvailability.TemporaryUnavailable
val integration = P2PEthPoolIntegration(StakingIntegrationID.P2PEthPool, vaults, limits)
return if (integration.areAllTargetsFull) {
StakingAvailability.Full(StakingOption.P2PEthPool(vaults))
} else {
StakingAvailability.Available(StakingOption.P2PEthPool(vaults))
}
@ -203,4 +225,33 @@ internal class DefaultP2PEthPoolRepository(
override suspend fun getVaultsSync(): List<P2PEthPoolVault> {
return p2pEthPoolVaultsStore.getSync()
}
override suspend fun fetchVaultLimits() {
runSuspendCatching {
val response = withContext(dispatchers.io) {
tangemTechApi.getCoinsSettings().getOrThrow()
}
val vaults = response.staking?.vaults.orEmpty()
val limits = vaults
.mapNotNull { vault ->
val limit = vault.limit ?: return@mapNotNull null
vault.vaultAddress.lowercase() to VaultLimitInfo(
limit = limit,
coefficient = vault.coefficient,
)
}
.toMap()
p2pVaultLimitsStore.store(limits)
}.onFailure { e ->
TangemLogger.e("Error fetching P2P vault limits: ${e.message}", e)
}
}
override fun getVaultLimitsFlow(): Flow<Map<String, VaultLimitInfo>?> {
return p2pVaultLimitsStore.get()
}
override suspend fun getVaultLimitsSyncOrNull(): Map<String, VaultLimitInfo>? {
return p2pVaultLimitsStore.getSyncOrNull()
}
}

View file

@ -117,6 +117,7 @@ internal object YieldConverter : Converter<YieldDTO, Yield> {
private fun convertPeriod(periodDTO: YieldDTO.MetadataDTO.PeriodDTO): Yield.Metadata.Period {
return Yield.Metadata.Period(
days = periodDTO.days.asMandatory("days"),
seconds = periodDTO.seconds,
)
}

View file

@ -1,11 +1,8 @@
package com.tangem.data.staking.converters.ethpool
import com.tangem.datasource.api.ethpool.models.response.P2PEthPoolBroadcastResponse
import com.tangem.datasource.api.ethpool.models.response.P2PEthPoolTxStatusDTO
import com.tangem.domain.staking.model.ethpool.P2PEthPoolBroadcastResult
import com.tangem.domain.staking.model.ethpool.P2PEthPoolBroadcastStatus
import com.tangem.utils.converter.Converter
import java.math.BigDecimal
/**
* Converter from P2PEthPool Broadcast Transaction Response to Domain model
@ -15,21 +12,14 @@ internal object P2PEthPoolBroadcastResultConverter : Converter<P2PEthPoolBroadca
override fun convert(value: P2PEthPoolBroadcastResponse): P2PEthPoolBroadcastResult {
return P2PEthPoolBroadcastResult(
hash = value.hash,
status = convertStatus(value.status),
status = value.status,
blockNumber = value.blockNumber,
transactionIndex = value.transactionIndex,
gasUsed = value.gasUsed.toBigDecimalOrNull() ?: BigDecimal.ZERO,
cumulativeGasUsed = value.cumulativeGasUsed.toBigDecimalOrNull() ?: BigDecimal.ZERO,
gasUsed = value.gasUsed?.toBigDecimalOrNull(),
cumulativeGasUsed = value.cumulativeGasUsed?.toBigDecimalOrNull(),
effectiveGasPrice = value.effectiveGasPrice?.toBigDecimalOrNull(),
from = value.from,
to = value.to,
)
}
private fun convertStatus(status: P2PEthPoolTxStatusDTO): P2PEthPoolBroadcastStatus {
return when (status) {
P2PEthPoolTxStatusDTO.SUCCESS -> P2PEthPoolBroadcastStatus.SUCCESS
P2PEthPoolTxStatusDTO.FAILED -> P2PEthPoolBroadcastStatus.FAILED
}
}
}

View file

@ -12,9 +12,11 @@ import com.tangem.data.staking.utils.DefaultStakingCleaner
import com.tangem.datasource.api.ethpool.P2PEthPoolApi
import com.tangem.datasource.api.stakekit.StakeKitApi
import com.tangem.datasource.api.stakekit.models.response.model.error.StakeKitErrorResponse
import com.tangem.datasource.api.tangemTech.TangemTechApi
import com.tangem.datasource.di.NetworkMoshi
import com.tangem.datasource.local.preferences.AppPreferencesStore
import com.tangem.datasource.local.token.P2PEthPoolVaultsStore
import com.tangem.datasource.local.token.P2PVaultLimitsStore
import com.tangem.datasource.local.token.StakingActionsStore
import com.tangem.datasource.local.token.StakingYieldsStore
import com.tangem.domain.staking.StakingIdFactory
@ -77,12 +79,16 @@ internal object StakingDataModule {
fun provideP2PEthPoolRepository(
p2pEthPoolApi: P2PEthPoolApi,
p2pEthPoolVaultsStore: P2PEthPoolVaultsStore,
p2pVaultLimitsStore: P2PVaultLimitsStore,
tangemTechApi: TangemTechApi,
dispatchers: CoroutineDispatcherProvider,
stakingFeatureToggles: StakingFeatureToggles,
): P2PEthPoolRepository {
return DefaultP2PEthPoolRepository(
p2pEthPoolApi = p2pEthPoolApi,
p2pEthPoolVaultsStore = p2pEthPoolVaultsStore,
p2pVaultLimitsStore = p2pVaultLimitsStore,
tangemTechApi = tangemTechApi,
dispatchers = dispatchers,
stakingFeatureToggles = stakingFeatureToggles,
)

View file

@ -0,0 +1,120 @@
package com.tangem.data.staking
import com.google.common.truth.Truth.assertThat
import com.tangem.datasource.api.ethpool.P2PEthPoolApi
import com.tangem.datasource.api.tangemTech.TangemTechApi
import com.tangem.datasource.local.token.P2PEthPoolVaultsStore
import com.tangem.datasource.local.token.P2PVaultLimitsStore
import com.tangem.domain.staking.model.StakingAvailability
import com.tangem.domain.staking.model.ethpool.P2PEthPoolVault
import com.tangem.domain.staking.model.ethpool.VaultLimitInfo
import com.tangem.domain.staking.toggles.StakingFeatureToggles
import com.tangem.utils.coroutines.TestingCoroutineDispatcherProvider
import io.mockk.coEvery
import io.mockk.every
import io.mockk.mockk
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.first
import kotlinx.coroutines.flow.flowOf
import kotlinx.coroutines.test.runTest
import org.junit.jupiter.api.Test
import org.junit.jupiter.api.TestInstance
import java.math.BigDecimal
@TestInstance(TestInstance.Lifecycle.PER_CLASS)
internal class DefaultP2PEthPoolRepositoryAvailabilityTest {
private val api = mockk<P2PEthPoolApi>(relaxed = true)
private val vaultsStore = mockk<P2PEthPoolVaultsStore>(relaxed = true)
private val limitsStore = mockk<P2PVaultLimitsStore>(relaxed = true)
private val tangemTechApi = mockk<TangemTechApi>(relaxed = true)
private val featureToggles = mockk<StakingFeatureToggles>(relaxed = true)
private val repository = DefaultP2PEthPoolRepository(
p2pEthPoolApi = api,
p2pEthPoolVaultsStore = vaultsStore,
p2pVaultLimitsStore = limitsStore,
tangemTechApi = tangemTechApi,
dispatchers = TestingCoroutineDispatcherProvider(),
stakingFeatureToggles = featureToggles,
)
private fun buildVault(address: String, totalAssets: String) = P2PEthPoolVault(
vaultAddress = address,
displayName = "Vault",
apy = BigDecimal("4.5"),
baseApy = BigDecimal("4.0"),
capacity = BigDecimal("1000"),
totalAssets = BigDecimal(totalAssets),
feePercent = BigDecimal("10"),
isPrivate = false,
isGenesis = false,
isSmoothingPool = true,
isErc20 = false,
tokenName = null,
tokenSymbol = null,
createdAt = 0L,
)
private fun limits(address: String, limit: String) =
mapOf(address.lowercase() to VaultLimitInfo(limit = BigDecimal(limit), coefficient = null))
@Test
fun `all vaults full - emits Full with option`() = runTest {
every { vaultsStore.get() } returns flowOf(listOf(buildVault("0xABC", totalAssets = "999.95")))
every { limitsStore.get() } returns MutableStateFlow(limits("0xABC", limit = "1000")) // remaining 0.05 <= 0.1
val result = repository.getStakingAvailability().first()
assertThat(result).isInstanceOf(StakingAvailability.Full::class.java)
}
@Test
fun `capacity available - emits Available`() = runTest {
every { vaultsStore.get() } returns flowOf(listOf(buildVault("0xABC", totalAssets = "100")))
every { limitsStore.get() } returns MutableStateFlow(limits("0xABC", limit = "1000")) // remaining 900 > 0.1
val result = repository.getStakingAvailability().first()
assertThat(result).isInstanceOf(StakingAvailability.Available::class.java)
}
@Test
fun `sync - all vaults full - returns Full with option`() = runTest {
coEvery { vaultsStore.getSync() } returns listOf(buildVault("0xABC", totalAssets = "999.95"))
coEvery { limitsStore.getSyncOrNull() } returns limits("0xABC", limit = "1000") // remaining 0.05 <= 0.1
val result = repository.getStakingAvailabilitySync()
assertThat(result).isInstanceOf(StakingAvailability.Full::class.java)
}
@Test
fun `sync - capacity available - returns Available`() = runTest {
coEvery { vaultsStore.getSync() } returns listOf(buildVault("0xABC", totalAssets = "100"))
coEvery { limitsStore.getSyncOrNull() } returns limits("0xABC", limit = "1000") // remaining 900 > 0.1
val result = repository.getStakingAvailabilitySync()
assertThat(result).isInstanceOf(StakingAvailability.Available::class.java)
}
@Test
fun `sync - empty vaults - returns TemporaryUnavailable`() = runTest {
coEvery { vaultsStore.getSync() } returns emptyList()
val result = repository.getStakingAvailabilitySync()
assertThat(result).isInstanceOf(StakingAvailability.TemporaryUnavailable::class.java)
}
@Test
fun `sync - limits not loaded - returns TemporaryUnavailable`() = runTest {
coEvery { vaultsStore.getSync() } returns listOf(buildVault("0xABC", totalAssets = "100"))
coEvery { limitsStore.getSyncOrNull() } returns null
val result = repository.getStakingAvailabilitySync()
assertThat(result).isInstanceOf(StakingAvailability.TemporaryUnavailable::class.java)
}
}

View file

@ -0,0 +1,106 @@
package com.tangem.data.staking
import com.google.common.truth.Truth.assertThat
import com.tangem.datasource.api.common.response.ApiResponse
import com.tangem.datasource.api.ethpool.P2PEthPoolApi
import com.tangem.datasource.api.ethpool.models.response.P2PEthPoolNetworkDTO
import com.tangem.datasource.api.ethpool.models.response.P2PEthPoolResponse
import com.tangem.datasource.api.ethpool.models.response.P2PEthPoolVaultDTO
import com.tangem.datasource.api.ethpool.models.response.P2PEthPoolVaultsResponse
import com.tangem.datasource.api.tangemTech.TangemTechApi
import com.tangem.datasource.local.token.P2PEthPoolVaultsStore
import com.tangem.datasource.local.token.P2PVaultLimitsStore
import com.tangem.domain.staking.model.StakingIntegrationID
import com.tangem.domain.staking.model.ethpool.P2PEthPoolNetwork
import com.tangem.domain.staking.toggles.StakingFeatureToggles
import com.tangem.utils.coroutines.TestingCoroutineDispatcherProvider
import io.mockk.coEvery
import io.mockk.every
import io.mockk.mockk
import kotlinx.coroutines.test.runTest
import org.junit.jupiter.api.Test
import org.junit.jupiter.api.TestInstance
import java.math.BigDecimal
@TestInstance(TestInstance.Lifecycle.PER_CLASS)
internal class P2PEthPoolVaultFilterTest {
private companion object {
const val PRODUCTION_VAULT_ADDRESS = "0x4c09BC47db288F998b33CD63BCc1b6ddCCe13F33"
const val TEST_VAULT_ADDRESS = "0xB72668D6FF7A0e318F83097A754c6AEd0f8AF034"
}
private val api = mockk<P2PEthPoolApi>()
private val store = mockk<P2PEthPoolVaultsStore>(relaxed = true)
private val limitsStore = mockk<P2PVaultLimitsStore>(relaxed = true)
private val tangemTechApi = mockk<TangemTechApi>(relaxed = true)
private val featureToggles = mockk<StakingFeatureToggles> {
every { isIntegrationEnabled(StakingIntegrationID.P2PEthPool) } returns true
}
private val repository = DefaultP2PEthPoolRepository(
p2pEthPoolApi = api,
p2pEthPoolVaultsStore = store,
p2pVaultLimitsStore = limitsStore,
tangemTechApi = tangemTechApi,
dispatchers = TestingCoroutineDispatcherProvider(),
stakingFeatureToggles = featureToggles,
)
private fun buildVaultDTO(address: String) = P2PEthPoolVaultDTO(
vaultAddress = address,
displayName = "Vault $address",
apy = BigDecimal("4.5"),
baseApy = BigDecimal("4.0"),
capacity = BigDecimal("10000"),
totalAssets = BigDecimal("5000"),
feePercent = BigDecimal("10"),
isPrivate = false,
isGenesis = false,
isSmoothingPool = true,
isErc20 = false,
tokenName = null,
tokenSymbol = null,
createdAt = 0L,
)
private fun successResponse(vararg addresses: String) = ApiResponse.Success(
P2PEthPoolResponse(
error = null,
result = P2PEthPoolVaultsResponse(
network = P2PEthPoolNetworkDTO.MAINNET,
vaults = addresses.map { buildVaultDTO(it) },
),
),
)
@Test
fun `test vault address is filtered from getVaults result`() = runTest {
coEvery { api.getVaults(any()) } returns successResponse(PRODUCTION_VAULT_ADDRESS, TEST_VAULT_ADDRESS)
val vaults = repository.getVaults(P2PEthPoolNetwork.MAINNET).getOrNull()
assertThat(vaults).hasSize(1)
assertThat(vaults?.first()?.vaultAddress).isEqualTo(PRODUCTION_VAULT_ADDRESS)
}
@Test
fun `production vault address passes filter`() = runTest {
coEvery { api.getVaults(any()) } returns successResponse(PRODUCTION_VAULT_ADDRESS)
val vaults = repository.getVaults(P2PEthPoolNetwork.MAINNET).getOrNull()
assertThat(vaults).hasSize(1)
}
@Test
fun `filter is case-insensitive`() = runTest {
coEvery { api.getVaults(any()) } returns successResponse(
TEST_VAULT_ADDRESS.uppercase(),
TEST_VAULT_ADDRESS.lowercase(),
)
val vaults = repository.getVaults(P2PEthPoolNetwork.MAINNET).getOrNull()
assertThat(vaults).isEmpty()
}
}

View file

@ -7,19 +7,17 @@ plugins {
}
android {
namespace = "com.tangem.data.promo"
namespace = "com.tangem.data.stories"
}
dependencies {
implementation(deps.androidx.datastore)
implementation(deps.jodatime)
implementation(deps.hilt.android)
kapt(deps.hilt.kapt)
implementation(projects.domain.promo)
implementation(projects.domain.promo.models)
implementation(projects.domain.stories)
implementation(projects.domain.stories.models)
api(projects.domain.models)
implementation(projects.domain.wallets.models)
implementation(projects.features.referral.domain)

View file

@ -0,0 +1,74 @@
package com.tangem.data.stories
import com.tangem.data.stories.converters.StoryContentResponseConverter
import com.tangem.datasource.api.common.response.getOrThrow
import com.tangem.datasource.api.tangemTech.TangemTechApi
import com.tangem.datasource.local.preferences.AppPreferencesStore
import com.tangem.datasource.local.preferences.PreferencesKeys
import com.tangem.datasource.local.preferences.utils.get
import com.tangem.datasource.local.preferences.utils.getSyncOrDefault
import com.tangem.datasource.local.preferences.utils.store
import com.tangem.datasource.local.stories.StoriesStore
import com.tangem.domain.stories.StoriesRepository
import com.tangem.domain.stories.models.StoryContent
import com.tangem.utils.coroutines.CoroutineDispatcherProvider
import com.tangem.utils.coroutines.runSuspendCatching
import kotlinx.coroutines.flow.*
import kotlinx.coroutines.withContext
import kotlinx.coroutines.withTimeoutOrNull
internal class DefaultStoriesRepository(
private val tangemApi: TangemTechApi,
private val appPreferencesStore: AppPreferencesStore,
private val promoStoriesStore: StoriesStore,
private val dispatchers: CoroutineDispatcherProvider,
) : StoriesRepository {
private val storyContentConverter = StoryContentResponseConverter()
override fun getStoryById(id: String): Flow<StoryContent?> = isReadyToShowStories(id).mapLatest {
getStoryByIdSync(id = id, refresh = false)
}
override suspend fun getStoryByIdSync(id: String, refresh: Boolean): StoryContent? = withContext(dispatchers.io) {
if (!isReadyToShowStoriesSync(id)) return@withContext null
val storedPromo = promoStoriesStore.getSyncOrNull(storyId = id)
// Get last stored promo by id if possible or get from network
val story = if (storedPromo == null && refresh) {
val storyContent = runSuspendCatching {
// Important to return
withTimeoutOrNull(STORIES_LOAD_DELAY) {
tangemApi.getStoryById(storyId = id).getOrThrow()
}
}.getOrNull()
if (storyContent != null) {
promoStoriesStore.store(id, storyContent)
}
storyContent
} else {
storedPromo
}
story?.let { storyContentConverter.convert(it) }
}
override fun isReadyToShowStories(storyId: String): Flow<Boolean> {
return appPreferencesStore.get(PreferencesKeys.getShouldShowStoriesKey(storyId), true)
}
override suspend fun isReadyToShowStoriesSync(storyId: String): Boolean {
return appPreferencesStore.getSyncOrDefault(PreferencesKeys.getShouldShowStoriesKey(storyId), true)
}
override suspend fun setNeverToShowStories(storyId: String) {
appPreferencesStore.store(
key = PreferencesKeys.getShouldShowStoriesKey(storyId),
value = false,
)
}
private companion object {
const val STORIES_LOAD_DELAY = 1000L
}
}

View file

@ -1,7 +1,7 @@
package com.tangem.data.promo.converters
package com.tangem.data.stories.converters
import com.tangem.datasource.api.promotion.models.StoryContentResponse
import com.tangem.domain.promo.models.StoryContent
import com.tangem.datasource.api.stories.models.StoryContentResponse
import com.tangem.domain.stories.models.StoryContent
import com.tangem.utils.converter.Converter
internal class StoryContentResponseConverter : Converter<StoryContentResponse, StoryContent> {

View file

@ -1,12 +1,10 @@
package com.tangem.data.promo.di
package com.tangem.data.stories.di
import com.tangem.data.promo.DefaultPromoRepository
import com.tangem.data.stories.DefaultStoriesRepository
import com.tangem.datasource.api.tangemTech.TangemTechApi
import com.tangem.datasource.local.preferences.AppPreferencesStore
import com.tangem.datasource.local.promo.PromoBannerStore
import com.tangem.datasource.local.promo.PromoStoriesStore
import com.tangem.domain.promo.PromoRepository
import com.tangem.feature.referral.domain.ReferralRepository
import com.tangem.datasource.local.stories.StoriesStore
import com.tangem.domain.stories.StoriesRepository
import com.tangem.utils.coroutines.CoroutineDispatcherProvider
import dagger.Module
import dagger.Provides
@ -16,25 +14,21 @@ import javax.inject.Singleton
@Module
@InstallIn(SingletonComponent::class)
internal object PromoDataModule {
internal object StoriesDataModule {
@Provides
@Singleton
fun providePromoRepository(
fun provideStoriesRepository(
tangemTechApi: TangemTechApi,
appPreferencesStore: AppPreferencesStore,
promoStoriesStore: PromoStoriesStore,
promoBannerStore: PromoBannerStore,
promoStoriesStore: StoriesStore,
dispatchers: CoroutineDispatcherProvider,
referralRepository: ReferralRepository,
): PromoRepository {
return DefaultPromoRepository(
): StoriesRepository {
return DefaultStoriesRepository(
tangemApi = tangemTechApi,
appPreferencesStore = appPreferencesStore,
promoStoriesStore = promoStoriesStore,
dispatchers = dispatchers,
referralRepository = referralRepository,
promoBannerStore = promoBannerStore,
)
}
}

View file

@ -1,55 +0,0 @@
package com.tangem.data.pay
import arrow.core.Either
import arrow.core.Either.Companion.catch
import com.tangem.blockchain.blockchains.ethereum.Chain
import com.tangem.blockchainsdk.utils.ExcludedBlockchains
import com.tangem.core.error.UniversalError
import com.tangem.data.common.currency.CryptoCurrencyFactory
import com.tangem.data.common.network.NetworkFactory
import com.tangem.data.pay.entity.TangemPayCurrencyFactory
import com.tangem.data.pay.util.TangemPayErrorConverter
import com.tangem.domain.models.currency.CryptoCurrency
import com.tangem.domain.models.wallet.UserWallet
import com.tangem.domain.pay.TangemPayCryptoCurrencyFactory
import com.tangem.utils.logging.TangemLogger
import javax.inject.Inject
private const val TAG = "TangemPay: DefaultTangemPayCryptoCurrencyFactory"
@Deprecated("Use TangemPayCurrencyFactory instead")
internal class DefaultTangemPayCryptoCurrencyFactory @Inject constructor(
excludedBlockchains: ExcludedBlockchains,
private val errorConverter: TangemPayErrorConverter,
) : TangemPayCryptoCurrencyFactory {
private val cryptoCurrencyFactory by lazy(mode = LazyThreadSafetyMode.NONE) {
CryptoCurrencyFactory(excludedBlockchains)
}
private val networkFactory by lazy(mode = LazyThreadSafetyMode.NONE) {
NetworkFactory(excludedBlockchains)
}
override fun create(userWallet: UserWallet, chainId: Int): Either<UniversalError, CryptoCurrency> {
return catch {
val chain = requireNotNull(Chain.entries.find { it.id == chainId }) { "Can not find chain with $chainId" }
val blockchain = requireNotNull(chain.blockchain)
val network = networkFactory.create(
blockchain = blockchain,
extraDerivationPath = null,
userWallet = userWallet,
)
cryptoCurrencyFactory.createToken(
network = requireNotNull(network),
rawId = CryptoCurrency.RawID(TangemPayCurrencyFactory.TOKEN_ID),
name = TangemPayCurrencyFactory.TOKEN_NAME,
symbol = TangemPayCurrencyFactory.TOKEN_NAME,
contractAddress = TangemPayCurrencyFactory.TOKEN_CONTRACT_ADDRESS,
decimals = TangemPayCurrencyFactory.TOKEN_DECIMALS,
)
}.mapLeft { exception ->
TangemLogger.withTag(TAG).e("Error", exception)
errorConverter.convert(exception)
}
}
}

View file

@ -1,7 +1,6 @@
package com.tangem.data.pay.converter
import arrow.core.getOrElse
import com.tangem.data.pay.entity.TangemPayCurrencyFactory
import com.tangem.datasource.local.visa.entity.PaymentAccountStatusValueDM
import com.tangem.domain.models.StatusSource
import com.tangem.domain.models.account.CardDisplayName
@ -11,6 +10,7 @@ import com.tangem.domain.models.pay.TangemPayCardLimit
import com.tangem.domain.models.pay.TangemPayCardLimitData
import com.tangem.domain.models.pay.TangemPayCardLimitPeriod
import com.tangem.domain.models.wallet.UserWalletId
import com.tangem.domain.pay.TangemPayCurrencyFactory
import javax.inject.Inject
import javax.inject.Singleton
@ -42,6 +42,7 @@ internal class PaymentAccountStatusValueDMConverter @Inject constructor(
fiatBalance = value.fiatBalance.toDM(),
cryptoBalance = value.cryptoBalance.toDM(),
availableForWithdrawal = value.availableForWithdrawal,
fiatRate = value.fiatRate,
cards = value.cards.map { card ->
PaymentAccountStatusValueDM.TangemPayCard(
id = card.id,
@ -60,7 +61,9 @@ internal class PaymentAccountStatusValueDMConverter @Inject constructor(
)
is PaymentAccountStatusValue.Empty -> PaymentAccountStatusValueDM.Empty()
is PaymentAccountStatusValue.Deactivated -> PaymentAccountStatusValueDM.DeactivatedAccount(
fiatRate = value.fiatRate,
fiatBalance = value.fiatBalance.toDM(),
cryptoBalance = value.cryptoBalance.toDM(),
)
// Transient statuses are not persisted
is PaymentAccountStatusValue.Loading,
@ -72,6 +75,7 @@ internal class PaymentAccountStatusValueDMConverter @Inject constructor(
}
fun convertBack(userWalletId: UserWalletId, value: PaymentAccountStatusValueDM?): PaymentAccountStatusValue {
val cryptoCurrency = tangemPayCurrencyFactory.create(userWalletId)
return when (value) {
is PaymentAccountStatusValueDM.Empty -> PaymentAccountStatusValue.Empty
is PaymentAccountStatusValueDM.NotCreated -> PaymentAccountStatusValue.NotCreated
@ -89,7 +93,8 @@ internal class PaymentAccountStatusValueDMConverter @Inject constructor(
fiatBalance = value.fiatBalance.toDomain(),
cryptoBalance = value.cryptoBalance.toDomain(),
availableForWithdrawal = value.availableForWithdrawal,
cryptoCurrency = tangemPayCurrencyFactory.create(userWalletId),
cryptoCurrency = cryptoCurrency,
fiatRate = value.fiatRate,
cards = value.cards.map { card ->
TangemPayCard(
id = card.id,
@ -117,6 +122,9 @@ internal class PaymentAccountStatusValueDMConverter @Inject constructor(
is PaymentAccountStatusValueDM.DeactivatedAccount -> PaymentAccountStatusValue.Deactivated(
source = StatusSource.CACHE,
fiatBalance = value.fiatBalance.toDomain(),
cryptoBalance = value.cryptoBalance.toDomain(),
cryptoCurrency = cryptoCurrency,
fiatRate = value.fiatRate,
)
null -> PaymentAccountStatusValue.Error.Unavailable
}

View file

@ -5,9 +5,9 @@ import androidx.datastore.core.DataStoreFactory
import androidx.datastore.core.handlers.ReplaceFileCorruptionHandler
import androidx.datastore.dataStoreFile
import com.squareup.moshi.Moshi
import com.tangem.data.pay.DefaultTangemPayCryptoCurrencyFactory
import com.tangem.data.pay.DefaultTangemPayEligibilityManager
import com.tangem.data.pay.converter.PaymentAccountStatusValueDMConverter
import com.tangem.data.pay.entity.DefaultTangemPayCurrencyFactory
import com.tangem.data.pay.flow.DefaultPaymentAccountStatusFetcher
import com.tangem.data.pay.flow.DefaultPaymentAccountStatusProducer
import com.tangem.data.pay.repository.*
@ -20,19 +20,13 @@ import com.tangem.datasource.local.datastore.RuntimeSharedStore
import com.tangem.datasource.local.visa.entity.PaymentAccountStatusValueDM
import com.tangem.datasource.utils.MoshiDataStoreSerializer
import com.tangem.datasource.utils.mapWithStringKeyTypes
import com.tangem.domain.pay.TangemPayCryptoCurrencyFactory
import com.tangem.domain.pay.TangemPayCurrencyFactory
import com.tangem.domain.pay.TangemPayEligibilityManager
import com.tangem.domain.pay.flow.PaymentAccountStatusFetcher
import com.tangem.domain.pay.flow.PaymentAccountStatusProducer
import com.tangem.domain.pay.flow.PaymentAccountStatusSupplier
import com.tangem.domain.pay.repository.*
import com.tangem.domain.pay.usecase.ChangeCardFrozenStateUseCase
import com.tangem.domain.pay.usecase.GetPaymentAccountCryptoCurrencyStatusUseCase
import com.tangem.domain.pay.usecase.ProduceTangemPayInitialDataUseCase
import com.tangem.domain.pay.usecase.ReissueTangemPayCardUseCase
import com.tangem.domain.pay.usecase.SetTangemPayCardLimitUseCase
import com.tangem.domain.pay.usecase.StartTangemPayOrderPollingUseCase
import com.tangem.domain.pay.usecase.UpdateTangemPayCardNameUseCase
import com.tangem.domain.pay.usecase.*
import com.tangem.domain.tangempay.GetTangemPayCurrencyStatusUseCase
import com.tangem.domain.tangempay.GetTangemPayCustomerIdUseCase
import com.tangem.domain.tangempay.TangemPayWithdrawUseCase
@ -73,9 +67,7 @@ internal interface TangemPayDataModule {
@Binds
@Singleton
fun bindTangemPayCryptoCurrencyFactory(
factory: DefaultTangemPayCryptoCurrencyFactory,
): TangemPayCryptoCurrencyFactory
fun bindTangemPayCryptoCurrencyFactory(factory: DefaultTangemPayCurrencyFactory): TangemPayCurrencyFactory
@Binds
@Singleton

View file

@ -8,20 +8,21 @@ import com.tangem.domain.common.wallets.UserWalletsListRepository
import com.tangem.domain.common.wallets.requireUserWalletsSync
import com.tangem.domain.models.currency.CryptoCurrency
import com.tangem.domain.models.wallet.UserWalletId
import com.tangem.domain.pay.TangemPayCurrencyFactory
import javax.inject.Inject
import javax.inject.Singleton
@Singleton
internal class TangemPayCurrencyFactory @Inject constructor(
internal class DefaultTangemPayCurrencyFactory @Inject constructor(
excludedBlockchains: ExcludedBlockchains,
private val userWalletsListRepository: UserWalletsListRepository,
private val networkFactory: NetworkFactory,
) {
) : TangemPayCurrencyFactory {
private val cryptoCurrencyFactory by lazy(mode = LazyThreadSafetyMode.NONE) {
CryptoCurrencyFactory(excludedBlockchains)
}
fun create(userWalletId: UserWalletId): CryptoCurrency.Token {
override fun create(userWalletId: UserWalletId): CryptoCurrency.Token {
val userWallet = userWalletsListRepository.requireUserWalletsSync()
.firstOrNull { it.walletId == userWalletId }
?: error("User wallet with id $userWalletId not found")
@ -32,18 +33,11 @@ internal class TangemPayCurrencyFactory @Inject constructor(
)
return cryptoCurrencyFactory.createToken(
network = requireNotNull(network),
rawId = CryptoCurrency.RawID(TOKEN_ID),
name = TOKEN_NAME,
symbol = TOKEN_NAME,
contractAddress = TOKEN_CONTRACT_ADDRESS,
decimals = TOKEN_DECIMALS,
rawId = TangemPayCurrencyFactory.TOKEN_ID,
name = TangemPayCurrencyFactory.TOKEN_NAME,
symbol = TangemPayCurrencyFactory.TOKEN_NAME,
contractAddress = TangemPayCurrencyFactory.TOKEN_CONTRACT_ADDRESS,
decimals = TangemPayCurrencyFactory.TOKEN_DECIMALS,
)
}
companion object {
internal const val TOKEN_ID = "usd-coin"
internal const val TOKEN_NAME = "USDC"
internal const val TOKEN_CONTRACT_ADDRESS = "0x3c499c542cef5e3811e1192ce70d8cc03d5c3359"
internal const val TOKEN_DECIMALS = 6
}
}

View file

@ -1,7 +1,6 @@
package com.tangem.data.pay.flow
import arrow.core.Either
import com.tangem.data.pay.entity.TangemPayCurrencyFactory
import com.tangem.data.pay.store.PaymentAccountStatusesStore
import com.tangem.domain.core.utils.catchOn
import com.tangem.domain.models.StatusSource
@ -11,7 +10,9 @@ import com.tangem.domain.models.account.PaymentAccountStatusValue
import com.tangem.domain.models.kyc.KycStatus
import com.tangem.domain.models.pay.TangemPayCard
import com.tangem.domain.models.pay.TangemPayCardLimitData
import com.tangem.domain.models.quote.QuoteStatus
import com.tangem.domain.models.wallet.UserWalletId
import com.tangem.domain.pay.TangemPayCurrencyFactory
import com.tangem.domain.pay.TangemPayEligibilityManager
import com.tangem.domain.pay.flow.PaymentAccountStatusFetcher
import com.tangem.domain.pay.model.CustomerInfo
@ -21,6 +22,8 @@ import com.tangem.domain.pay.model.TangemPayEntryPoint
import com.tangem.domain.pay.repository.CustomerOrderRepository
import com.tangem.domain.pay.repository.OnboardingRepository
import com.tangem.domain.pay.repository.TangemPayReissueCardRepository
import com.tangem.domain.quotes.single.SingleQuoteStatusProducer
import com.tangem.domain.quotes.single.SingleQuoteStatusSupplier
import com.tangem.domain.visa.error.VisaApiError
import com.tangem.domain.visa.model.TangemPayCardFrozenState
import com.tangem.security.DeviceSecurityInfoProvider
@ -30,6 +33,7 @@ import com.tangem.utils.logging.TangemLogger
import kotlinx.coroutines.currentCoroutineContext
import kotlinx.coroutines.delay
import kotlinx.coroutines.isActive
import java.math.BigDecimal
import javax.inject.Inject
import kotlin.time.Duration.Companion.minutes
@ -45,6 +49,7 @@ internal class DefaultPaymentAccountStatusFetcher @Inject constructor(
private val tangemPayCurrencyFactory: TangemPayCurrencyFactory,
private val eligibilityManager: TangemPayEligibilityManager,
private val reissueCardRepository: TangemPayReissueCardRepository,
private val singleQuoteSupplier: SingleQuoteStatusSupplier,
) : PaymentAccountStatusFetcher {
private val logger = TangemLogger.withTag(TAG)
@ -257,12 +262,16 @@ internal class DefaultPaymentAccountStatusFetcher @Inject constructor(
}
private suspend fun CustomerInfo.mapToPaymentAccountStatus(userWalletId: UserWalletId): PaymentAccountStatusValue {
val quotesData = singleQuoteSupplier.getSyncOrNull(
params = SingleQuoteStatusProducer.Params(rawCurrencyId = TangemPayCurrencyFactory.TOKEN_ID),
)?.value as? QuoteStatus.Data
val cardInfo = this.cardInfo
val productInstance = this.productInstance
val isDeactivated = productInstance?.status == CustomerInfo.ProductInstance.Status.DEACTIVATED
val isFormer = state == CustomerInfo.State.FORMER
val fiatBalance = fiatBalance
val cryptoBalance = cryptoBalance
return when {
kycStatus != KycStatus.APPROVED && !customerId.isNullOrEmpty() -> {
@ -272,16 +281,20 @@ internal class DefaultPaymentAccountStatusFetcher @Inject constructor(
customerId = requireNotNull(customerId) { "CustomerId must not be null" },
)
}
fiatBalance != null && (isDeactivated || isFormer) -> {
fiatBalance != null && cryptoBalance != null && (isDeactivated || isFormer) -> {
PaymentAccountStatusValue.Deactivated(
source = StatusSource.ACTUAL,
fiatBalance = fiatBalance,
cryptoBalance = cryptoBalance,
cryptoCurrency = tangemPayCurrencyFactory.create(userWalletId),
fiatRate = quotesData?.fiatRate,
)
}
cardInfo != null && productInstance != null && !customerId.isNullOrEmpty() -> convertToContentState(
userWalletId = userWalletId,
productInstance = productInstance,
cardInfo = cardInfo,
fiatRate = quotesData?.fiatRate,
customerId = requireNotNull(customerId) { "CustomerId must not be null" },
)
else -> PaymentAccountStatusValue.IssuingCard(source = StatusSource.ACTUAL)
@ -293,6 +306,7 @@ internal class DefaultPaymentAccountStatusFetcher @Inject constructor(
productInstance: CustomerInfo.ProductInstance,
cardInfo: CustomerInfo.CardInfo,
customerId: String,
fiatRate: BigDecimal?,
): PaymentAccountStatusValue {
val reissueOrder = reissueCardRepository.getReissueOrderInfo(
userWalletId = userWalletId,
@ -313,6 +327,7 @@ internal class DefaultPaymentAccountStatusFetcher @Inject constructor(
cryptoBalance = cardInfo.cryptoBalance,
availableForWithdrawal = cardInfo.availableForWithdrawal,
cryptoCurrency = cryptoCurrency,
fiatRate = fiatRate,
cards = listOf(
TangemPayCard(
id = productInstance.cardId,

View file

@ -2,40 +2,33 @@ package com.tangem.data.pay.repository
import arrow.core.Either
import arrow.core.flatMap
import arrow.core.getOrElse
import arrow.core.left
import arrow.core.right
import com.tangem.core.analytics.api.AnalyticsEventHandler
import com.tangem.data.pay.store.PaymentAccountStatusesStore
import com.tangem.data.pay.util.CustomerInfoConverter
import com.tangem.datasource.api.pay.TangemPayApi
import com.tangem.datasource.api.pay.models.request.DeeplinkValidityRequest
import com.tangem.datasource.api.pay.models.request.OrderRequest
import com.tangem.datasource.api.pay.models.request.SetTangemPayEnabledRequest
import com.tangem.datasource.api.pay.models.response.CustomerMeResponse
import com.tangem.datasource.api.pay.models.response.FiatBalance
import com.tangem.datasource.api.pay.models.response.OrderResponse
import com.tangem.datasource.local.visa.TangemPayCardFrozenStateStore
import com.tangem.datasource.local.visa.TangemPayStorage
import com.tangem.domain.common.wallets.UserWalletsListRepository
import com.tangem.domain.models.account.Account
import com.tangem.domain.models.account.AccountStatus
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.models.pay.TangemPayCardLimitPeriod
import com.tangem.domain.models.pay.TangemPayEligibilityType
import com.tangem.domain.models.wallet.UserWallet
import com.tangem.domain.models.wallet.UserWalletId
import com.tangem.domain.pay.datasource.TangemPayAuthDataSource
import com.tangem.domain.pay.model.CustomerInfo
import com.tangem.domain.pay.model.CustomerInfo.CardInfo
import com.tangem.domain.pay.model.CustomerInfo.ProductInstance
import com.tangem.domain.pay.repository.OnboardingRepository
import com.tangem.domain.tangempay.TangemPayAnalyticsEvents
import com.tangem.domain.visa.error.VisaApiError
import com.tangem.domain.visa.model.TangemPayCardFrozenState
import com.tangem.utils.coroutines.CoroutineDispatcherProvider
import com.tangem.utils.extensions.orZero
import kotlinx.coroutines.withContext
import java.util.concurrent.ConcurrentHashMap
import javax.inject.Inject
@ -100,10 +93,10 @@ internal class DefaultOnboardingRepository @Inject constructor(
override suspend fun getCustomerInfo(userWalletId: UserWalletId): Either<VisaApiError, CustomerInfo> {
return requestHelper.performRequest(userWalletId) { authHeader -> tangemPayApi.getCustomerMe(authHeader) }
.flatMap { response ->
val result = response.result
val status = result?.productInstance?.status
val result = response.result ?: return@flatMap VisaApiError.UnknownWithoutCode.left()
val status = result.productInstance?.status
val isDeactivated = status == CustomerMeResponse.ProductInstance.Status.DEACTIVATED
val isFormer = result?.state?.let { CustomerInfo.State.fromString(it) } == CustomerInfo.State.FORMER
val isFormer = result.state.let { CustomerInfo.State.fromString(it) } == CustomerInfo.State.FORMER
if (isDeactivated || isFormer) {
tangemPayStorage.storeIsTangemPayDeactivated(userWalletId)
}
@ -167,72 +160,16 @@ internal class DefaultOnboardingRepository @Inject constructor(
@Suppress("ComplexCondition")
private suspend fun getCustomerInfo(
userWalletId: UserWalletId,
response: CustomerMeResponse.Result?,
response: CustomerMeResponse.Result,
): CustomerInfo {
val kycStatus = KycStatus.fromString(status = response?.kyc?.status)
sendKycAnalytics(kycStatus)
val customerInfo = CustomerInfoConverter.convert(response)
sendKycAnalytics(customerInfo.kycStatus)
val card = response?.card
val fiatBalance = response?.balance?.fiat
val cryptoBalance = response?.balance?.crypto
val availableForWithdrawal = response?.balance?.availableForWithdrawal
val paymentAccount = response?.paymentAccount
val cardInfo = if (paymentAccount != null && card != null && fiatBalance != null && cryptoBalance != null) {
CardInfo(
lastFourDigits = card.cardNumberEnd,
balance = fiatBalance.availableBalance,
currencyCode = fiatBalance.currency,
depositAddress = response.depositAddress,
isPinSet = response.card?.isPinSet == true,
fiatBalance = fiatBalance.toDomain(),
cryptoBalance = PaymentAccountStatusValue.CryptoBalance(
id = cryptoBalance.id,
chainId = cryptoBalance.chainId.toLong(),
depositAddress = cryptoBalance.depositAddress.orEmpty(),
tokenContractAddress = cryptoBalance.tokenContractAddress,
balance = cryptoBalance.balance,
),
availableForWithdrawal = availableForWithdrawal?.amount.orZero(),
)
} else {
null
customerInfo.productInstance?.let { instance ->
cardFrozenStateStore.store(key = instance.cardId, value = instance.frozenState)
}
val productInstance = response?.productInstance?.let { instance ->
val cardFrozenState = when (instance.status) {
CustomerMeResponse.ProductInstance.Status.ACTIVE -> TangemPayCardFrozenState.Unfrozen
else -> TangemPayCardFrozenState.Frozen
}
cardFrozenStateStore.store(key = instance.cardId, value = cardFrozenState)
val displayName = instance.displayName?.ifEmpty { null }
ProductInstance(
id = instance.id,
cardId = instance.cardId,
frozenState = cardFrozenState,
status = instance.status.toDomain(),
displayName = if (displayName != null) CardDisplayName(displayName).getOrElse { null } else null,
actualCardLimit = instance.actualCardLimit?.parseCardLimit(),
adminCardLimit = instance.adminCardLimit?.parseCardLimit(),
)
}
return CustomerInfo(
customerId = response?.id,
productInstance = productInstance,
kycStatus = kycStatus,
cardInfo = cardInfo,
state = response?.state?.let { CustomerInfo.State.fromString(it) } ?: CustomerInfo.State.UNDEFINED,
fiatBalance = fiatBalance?.toDomain(),
).also {
lastFetchedCustomerInfoMap[userWalletId] = it
}
}
private fun CustomerMeResponse.CardLimit.parseCardLimit(): TangemPayCardLimit {
return TangemPayCardLimit(
amount = amount,
period = TangemPayCardLimitPeriod.fromString(periodType),
)
return customerInfo.also { lastFetchedCustomerInfoMap[userWalletId] = it }
}
private fun sendKycAnalytics(kycStatus: KycStatus) {
@ -311,24 +248,4 @@ internal class DefaultOnboardingRepository @Inject constructor(
setHideMainOnboardingBanner(userWalletId)
}
}
}
private fun FiatBalance.toDomain() = PaymentAccountStatusValue.FiatBalance(
availableBalance = availableBalance,
currency = currency,
)
private fun CustomerMeResponse.ProductInstance.Status.toDomain() = when (this) {
CustomerMeResponse.ProductInstance.Status.NEW -> ProductInstance.Status.NEW
CustomerMeResponse.ProductInstance.Status.READY_FOR_MANUFACTURING -> ProductInstance.Status.READY_FOR_MANUFACTURING
CustomerMeResponse.ProductInstance.Status.MANUFACTURING -> ProductInstance.Status.MANUFACTURING
CustomerMeResponse.ProductInstance.Status.SENT_TO_DELIVERY -> ProductInstance.Status.SENT_TO_DELIVERY
CustomerMeResponse.ProductInstance.Status.DELIVERED -> ProductInstance.Status.DELIVERED
CustomerMeResponse.ProductInstance.Status.ACTIVATING -> ProductInstance.Status.ACTIVATING
CustomerMeResponse.ProductInstance.Status.ACTIVE -> ProductInstance.Status.ACTIVE
CustomerMeResponse.ProductInstance.Status.BLOCKED -> ProductInstance.Status.BLOCKED
CustomerMeResponse.ProductInstance.Status.DEACTIVATING -> ProductInstance.Status.DEACTIVATING
CustomerMeResponse.ProductInstance.Status.DEACTIVATED -> ProductInstance.Status.DEACTIVATED
CustomerMeResponse.ProductInstance.Status.CANCELED -> ProductInstance.Status.CANCELED
CustomerMeResponse.ProductInstance.Status.UNKNOWN -> ProductInstance.Status.UNKNOWN
}

View file

@ -11,6 +11,7 @@ import com.tangem.datasource.api.pay.models.response.WithdrawResponse
import com.tangem.datasource.local.visa.TangemPayStorage
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.TangemPayWithdrawState
import com.tangem.domain.pay.WithdrawalResult
@ -222,13 +223,13 @@ internal class DefaultTangemPayWithdrawRepository @Inject constructor(
}
}
override suspend fun hasWithdrawOrder(userWallet: UserWallet): Boolean {
val orderId = tangemPayStorage.getActiveWithdrawOrderId(userWallet.walletId)
override suspend fun hasWithdrawOrder(userWalletId: UserWalletId): Boolean {
val orderId = tangemPayStorage.getActiveWithdrawOrderId(userWalletId)
if (orderId.isNullOrEmpty()) return false
val orderData = orderRepository.getOrderData(userWalletId = userWallet.walletId, orderId = orderId).getOrNull()
val orderData = orderRepository.getOrderData(userWalletId = userWalletId, orderId = orderId).getOrNull()
val isActive = orderData?.status == OrderStatus.NEW || orderData?.status == OrderStatus.PROCESSING
if (!isActive) {
tangemPayStorage.deleteActiveWithdrawOrder(userWalletId = userWallet.walletId)
tangemPayStorage.deleteActiveWithdrawOrder(userWalletId = userWalletId)
}
return isActive
}

View file

@ -0,0 +1,105 @@
package com.tangem.data.pay.util
import arrow.core.getOrElse
import com.tangem.datasource.api.pay.models.response.CryptoBalance
import com.tangem.datasource.api.pay.models.response.CustomerMeResponse
import com.tangem.datasource.api.pay.models.response.FiatBalance
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.models.pay.TangemPayCardLimitPeriod
import com.tangem.domain.pay.model.CustomerInfo
import com.tangem.domain.pay.model.CustomerInfo.CardInfo
import com.tangem.domain.pay.model.CustomerInfo.ProductInstance
import com.tangem.domain.pay.model.CustomerInfo.ProductInstance.Status
import com.tangem.domain.visa.model.TangemPayCardFrozenState
import com.tangem.utils.converter.Converter
import com.tangem.utils.extensions.orZero
internal object CustomerInfoConverter : Converter<CustomerMeResponse.Result, CustomerInfo> {
@Suppress("ComplexCondition")
override fun convert(value: CustomerMeResponse.Result): CustomerInfo {
val kycStatus = KycStatus.fromString(status = value.kyc?.status)
val card = value.card
val fiatBalance = value.balance?.fiat
val cryptoBalance = value.balance?.crypto
val paymentAccount = value.paymentAccount
val cardInfo = if (paymentAccount != null && card != null && fiatBalance != null && cryptoBalance != null) {
CardInfo(
lastFourDigits = card.cardNumberEnd,
balance = fiatBalance.availableBalance,
currencyCode = fiatBalance.currency,
depositAddress = value.depositAddress,
isPinSet = value.card?.isPinSet == true,
fiatBalance = fiatBalance.toDomain(),
cryptoBalance = cryptoBalance.toDomain(),
availableForWithdrawal = value.balance?.availableForWithdrawal?.amount.orZero(),
)
} else {
null
}
val productInstance = value.productInstance?.let { instance ->
val status = instance.status.toDomain()
val cardFrozenState = when (status) {
Status.ACTIVE -> TangemPayCardFrozenState.Unfrozen
else -> TangemPayCardFrozenState.Frozen
}
val displayName = instance.displayName?.ifEmpty { null }
ProductInstance(
id = instance.id,
cardId = instance.cardId,
frozenState = cardFrozenState,
status = status,
displayName = if (displayName != null) CardDisplayName(displayName).getOrElse { null } else null,
actualCardLimit = instance.actualCardLimit?.parseCardLimit(),
adminCardLimit = instance.adminCardLimit?.parseCardLimit(),
)
}
return CustomerInfo(
customerId = value.id,
productInstance = productInstance,
kycStatus = kycStatus,
cardInfo = cardInfo,
state = CustomerInfo.State.fromString(value.state),
fiatBalance = fiatBalance?.toDomain(),
cryptoBalance = cryptoBalance?.toDomain(),
)
}
private fun CustomerMeResponse.CardLimit.parseCardLimit(): TangemPayCardLimit {
return TangemPayCardLimit(
amount = amount,
period = TangemPayCardLimitPeriod.fromString(periodType),
)
}
private fun FiatBalance.toDomain() = PaymentAccountStatusValue.FiatBalance(
availableBalance = availableBalance,
currency = currency,
)
private fun CryptoBalance.toDomain() = PaymentAccountStatusValue.CryptoBalance(
id = id,
chainId = chainId.toLong(),
depositAddress = depositAddress.orEmpty(),
tokenContractAddress = tokenContractAddress,
balance = balance,
)
private fun CustomerMeResponse.ProductInstance.Status.toDomain(): Status = when (this) {
CustomerMeResponse.ProductInstance.Status.NEW -> Status.NEW
CustomerMeResponse.ProductInstance.Status.READY_FOR_MANUFACTURING -> Status.READY_FOR_MANUFACTURING
CustomerMeResponse.ProductInstance.Status.MANUFACTURING -> Status.MANUFACTURING
CustomerMeResponse.ProductInstance.Status.SENT_TO_DELIVERY -> Status.SENT_TO_DELIVERY
CustomerMeResponse.ProductInstance.Status.DELIVERED -> Status.DELIVERED
CustomerMeResponse.ProductInstance.Status.ACTIVATING -> Status.ACTIVATING
CustomerMeResponse.ProductInstance.Status.ACTIVE -> Status.ACTIVE
CustomerMeResponse.ProductInstance.Status.BLOCKED -> Status.BLOCKED
CustomerMeResponse.ProductInstance.Status.DEACTIVATING -> Status.DEACTIVATING
CustomerMeResponse.ProductInstance.Status.DEACTIVATED -> Status.DEACTIVATED
CustomerMeResponse.ProductInstance.Status.CANCELED -> Status.CANCELED
CustomerMeResponse.ProductInstance.Status.UNKNOWN -> Status.UNKNOWN
}
}

View file

@ -2,6 +2,7 @@ package com.tangem.data.visa.utils
import com.squareup.moshi.Moshi
import com.tangem.datasource.api.pay.models.response.TangemPayTxHistoryResponse
import com.tangem.domain.pay.utils.TangemPayTxHistoryItemStatusConverter
import com.tangem.domain.visa.model.TangemPayTxHistoryItem
import com.tangem.utils.converter.Converter
import com.tangem.utils.extensions.isPositive
@ -32,10 +33,15 @@ internal class TangemPayTxHistoryItemConverter(moshi: Moshi) :
}
private fun convertSpend(id: String, spend: TangemPayTxHistoryResponse.Spend): TangemPayTxHistoryItem.Spend {
val rawDate = if (spend.amount.signum() < 0) {
spend.postedAt ?: spend.authorizedAt
} else {
spend.authorizedAt
}
return TangemPayTxHistoryItem.Spend(
id = id,
jsonRepresentation = spendAdapter.toJson(spend),
date = spend.authorizedAt.withLocalZone(),
date = rawDate.withLocalZone(),
amount = spend.amount,
currency = Currency.getInstance(spend.currency),
authorizedAmount = spend.authorizedAmount.orZero(),

View file

@ -1,17 +0,0 @@
package com.tangem.data.visa.utils
import com.tangem.domain.visa.model.TangemPayTxHistoryItem
import com.tangem.utils.converter.Converter
internal 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
}
}
}

View file

@ -1,11 +1,13 @@
package com.tangem.data.pay.converter
import com.google.common.truth.Truth.assertThat
import com.tangem.data.pay.entity.TangemPayCurrencyFactory
import com.tangem.datasource.local.visa.entity.PaymentAccountStatusValueDM
import com.tangem.domain.models.StatusSource
import com.tangem.domain.models.account.PaymentAccountStatusValue
import com.tangem.domain.models.currency.CryptoCurrency
import com.tangem.domain.models.wallet.UserWalletId
import com.tangem.domain.pay.TangemPayCurrencyFactory
import io.mockk.every
import io.mockk.mockk
import org.junit.jupiter.api.Nested
import org.junit.jupiter.api.Test
@ -17,9 +19,30 @@ internal class PaymentAccountStatusValueDMConverterTest {
private val tangemPayCurrencyFactory: TangemPayCurrencyFactory = mockk()
private val userWalletId = UserWalletId("1234567890ABCDEF")
private val cryptoCurrency: CryptoCurrency.Token = mockk()
init {
every { tangemPayCurrencyFactory.create(userWalletId) } returns cryptoCurrency
}
private val converter = PaymentAccountStatusValueDMConverter(tangemPayCurrencyFactory)
private fun cryptoBalance() = PaymentAccountStatusValue.CryptoBalance(
id = "usd-coin",
chainId = 137,
depositAddress = "0xDEPOSIT",
tokenContractAddress = "0xCONTRACT",
balance = BigDecimal("10"),
)
private fun cryptoBalanceDM() = PaymentAccountStatusValueDM.CryptoBalanceDM(
id = "usd-coin",
chainId = 137,
depositAddress = "0xDEPOSIT",
tokenContractAddress = "0xCONTRACT",
balance = BigDecimal("10"),
)
@Nested
@TestInstance(TestInstance.Lifecycle.PER_CLASS)
inner class Convert {
@ -44,7 +67,10 @@ internal class PaymentAccountStatusValueDMConverterTest {
fiatBalance = PaymentAccountStatusValue.FiatBalance(
availableBalance = BigDecimal("100"),
currency = "USD",
)
),
cryptoBalance = cryptoBalance(),
cryptoCurrency = cryptoCurrency,
fiatRate = BigDecimal("1.05"),
)
// WHEN
@ -55,6 +81,7 @@ internal class PaymentAccountStatusValueDMConverterTest {
val dm = result as PaymentAccountStatusValueDM.DeactivatedAccount
assertThat(dm.fiatBalance.availableBalance).isEqualTo(BigDecimal("100"))
assertThat(dm.fiatBalance.currency).isEqualTo("USD")
assertThat(dm.fiatRate).isEqualTo(BigDecimal("1.05"))
}
@Test
@ -117,7 +144,9 @@ internal class PaymentAccountStatusValueDMConverterTest {
fiatBalance = PaymentAccountStatusValueDM.FiatBalanceDM(
availableBalance = BigDecimal("200"),
currency = "EUR",
)
),
cryptoBalance = cryptoBalanceDM(),
fiatRate = BigDecimal("0.92"),
)
// WHEN
@ -129,6 +158,7 @@ internal class PaymentAccountStatusValueDMConverterTest {
assertThat(deactivated.source).isEqualTo(StatusSource.CACHE)
assertThat(deactivated.fiatBalance.availableBalance).isEqualTo(BigDecimal("200"))
assertThat(deactivated.fiatBalance.currency).isEqualTo("EUR")
assertThat(deactivated.fiatRate).isEqualTo(BigDecimal("0.92"))
}
@Test

View file

@ -21,6 +21,12 @@ internal data class WcSolanaSignTransactionRequest(
val feePayer: String?,
)
@JsonClass(generateAdapter = true)
internal data class WcSolanaSignAndSendTransactionRequest(
@Json(name = "transaction")
val transaction: String,
)
@JsonClass(generateAdapter = true)
internal data class WcSolanaSignAllTransactionRequest(
@Json(name = "transactions")

View file

@ -18,6 +18,7 @@ import com.tangem.data.walletconnect.utils.WcNamespaceConverter
import com.tangem.data.walletconnect.utils.WcNetworksConverter
import com.tangem.domain.walletconnect.model.HandleMethodError
import com.tangem.domain.walletconnect.model.WcSolanaMethod
import com.tangem.domain.walletconnect.model.WcSolanaMethod.*
import com.tangem.domain.walletconnect.model.WcSolanaMethodName
import com.tangem.domain.walletconnect.model.sdkcopy.WcSdkSessionRequest
import com.tangem.domain.walletconnect.repository.WcSessionsManager
@ -55,9 +56,11 @@ internal class WcSolanaNetwork(
.orEmpty()
val accountAddress = when (method) {
is WcSolanaMethod.SignAllTransaction -> anyAddress()
is WcSolanaMethod.SignMessage -> anyAddress()
is WcSolanaMethod.SignTransaction -> method.address ?: anyAddress()
is SignAllTransaction,
is SignMessage,
is SignAndSendTransaction,
-> anyAddress()
is SignTransaction -> method.address ?: anyAddress()
}
val walletNetwork = networksConverter
.findWalletNetworkForRequest(request, session, accountAddress)
@ -73,9 +76,10 @@ internal class WcSolanaNetwork(
networkDerivationsCount = networkDerivationsCount,
)
return when (method) {
is WcSolanaMethod.SignMessage -> factories.messageSign.create(context, method)
is WcSolanaMethod.SignTransaction -> factories.signTransaction.create(context, method)
is WcSolanaMethod.SignAllTransaction -> factories.signAllTransaction.create(context, method)
is SignMessage -> factories.messageSign.create(context, method)
is SignTransaction -> factories.signTransaction.create(context, method)
is SignAllTransaction -> factories.signAllTransaction.create(context, method)
is SignAndSendTransaction -> factories.signAndSendTransaction.create(context, method)
}.right()
}
@ -103,7 +107,7 @@ internal class WcSolanaNetwork(
.getOrElse { return it.left() }
?.let { request ->
val humanMsg = request.message.decodeBase58()?.toHexString().orEmpty()
WcSolanaMethod.SignMessage(
SignMessage(
pubKey = request.publicKey,
rawMessage = request.message,
humanMsg = humanMsg,
@ -111,10 +115,15 @@ internal class WcSolanaNetwork(
}
WcSolanaMethodName.SignTransaction -> moshi.fromJson<WcSolanaSignTransactionRequest>(rawParams)
.getOrElse { return it.left() }
?.let { request -> WcSolanaMethod.SignTransaction(request.transaction, request.feePayer) }
?.let { request -> SignTransaction(request.transaction, request.feePayer) }
WcSolanaMethodName.SendAllTransaction -> moshi.fromJson<WcSolanaSignAllTransactionRequest>(rawParams)
.getOrElse { return it.left() }
?.let { request -> WcSolanaMethod.SignAllTransaction(request.transactions) }
?.let { request -> SignAllTransaction(request.transactions) }
WcSolanaMethodName.SignAndSendTransaction -> moshi.fromJson<WcSolanaSignAndSendTransactionRequest>(
rawParams,
)
.getOrElse { return it.left() }
?.let { request -> SignAndSendTransaction(request.transaction) }
}.right()
}
@ -122,6 +131,7 @@ internal class WcSolanaNetwork(
val messageSign: WcSolanaMessageSignUseCase.Factory,
val signTransaction: WcSolanaSignTransactionUseCase.Factory,
val signAllTransaction: WcSolanaSignAllTransactionUseCase.Factory,
val signAndSendTransaction: WcSolanaSignAndSendTransactionUseCase.Factory,
)
companion object {

View file

@ -0,0 +1,139 @@
package com.tangem.data.walletconnect.network.solana
import arrow.core.left
import com.tangem.blockchain.blockchains.solana.SolanaTransactionHelper
import com.tangem.blockchain.common.TransactionData
import com.tangem.blockchain.extensions.decodeBase58
import com.tangem.blockchain.extensions.encodeBase58
import com.tangem.core.analytics.api.AnalyticsEventHandler
import com.tangem.data.walletconnect.respond.WcRespondService
import com.tangem.data.walletconnect.sign.BaseWcSignUseCase
import com.tangem.data.walletconnect.sign.SignCollector
import com.tangem.data.walletconnect.sign.SignStateConverter.toResult
import com.tangem.data.walletconnect.sign.WcMethodUseCaseContext
import com.tangem.data.walletconnect.utils.BlockAidVerificationDelegate
import com.tangem.domain.models.wallet.UserWallet
import com.tangem.domain.transaction.usecase.SendLargeSolanaTransactionUseCase
import com.tangem.domain.transaction.usecase.SendTransactionUseCase
import com.tangem.domain.walletconnect.WcAnalyticEvents.SolanaLargeTransactionStatus
import com.tangem.domain.walletconnect.error.parseSendError
import com.tangem.domain.walletconnect.model.WcSolanaMethod
import com.tangem.domain.walletconnect.usecase.method.BlockAidTransactionCheck
import com.tangem.domain.walletconnect.usecase.method.SignRequirements
import com.tangem.domain.walletconnect.usecase.method.WcSignState
import com.tangem.domain.walletconnect.usecase.method.WcTransactionUseCase
import com.tangem.lib.crypto.BlockchainUtils.SOLANA_TRANSACTION_SIZE_THRESHOLD_BYTES
import com.tangem.utils.logging.TangemLogger
import dagger.assisted.Assisted
import dagger.assisted.AssistedFactory
import dagger.assisted.AssistedInject
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.map
@Suppress("LongParameterList")
internal class WcSolanaSignAndSendTransactionUseCase @AssistedInject constructor(
override val respondService: WcRespondService,
override val analytics: AnalyticsEventHandler,
private val sendTransaction: SendTransactionUseCase,
private val sendLargeSolanaTransactionUseCase: SendLargeSolanaTransactionUseCase,
@Assisted override val context: WcMethodUseCaseContext,
@Assisted override val method: WcSolanaMethod.SignAndSendTransaction,
blockAidDelegate: BlockAidVerificationDelegate,
addressConverter: SolanaBlockAidAddressConverter,
) : BaseWcSignUseCase<Nothing, TransactionData>(),
WcTransactionUseCase,
SignRequirements {
override val securityStatus = blockAidDelegate.getSecurityStatus(
network = network,
method = method,
rawSdkRequest = rawSdkRequest,
session = session,
accountAddress = addressConverter.convert(context.accountAddress),
).map { lce -> lce.map { result -> BlockAidTransactionCheck.Result.Plain(result) } }
override suspend fun SignCollector<TransactionData>.onSign(state: WcSignState<TransactionData>) {
val hash = state.signModel.getTxHashFromCompiled()
val formattedHash = getFormattedHash(hash) // uses for flow sendLargeSolanaTransaction
if (context.session.wallet is UserWallet.Cold && isLargeHash(formattedHash)) {
// workaround for large transactions that cannot be signed directly by card
TangemLogger.w("The transaction hash is too large to be signed directly: ${formattedHash.size} bytes")
sendLargeSolanaTransactionUseCase(context.session.wallet as UserWallet.Cold, context.network, formattedHash)
.fold(
ifLeft = { error ->
analytics.send(SolanaLargeTransactionStatus(SolanaLargeTransactionStatus.Status.Failed))
TangemLogger.e(error.toString())
emit(state.toResult(parseSendError(error).left()))
},
ifRight = {
analytics.send(SolanaLargeTransactionStatus(SolanaLargeTransactionStatus.Status.Success))
val emptyRespond = ByteArray(0).formatAsSolanaSignature()
val respondResult = respondService.respond(rawSdkRequest, emptyRespond)
emit(state.toResult(respondResult))
},
)
} else {
val signedHash =
sendTransaction.invoke(txData = state.signModel, userWallet = wallet, network = network)
.onLeft { error ->
emit(state.toResult(parseSendError(error).left()))
}
.getOrNull()
?: return
val respond = signedHash.formatAsSolanaSignature()
val respondResult = respondService.respond(rawSdkRequest, respond)
emit(state.toResult(respondResult))
}
}
override fun invoke(): Flow<WcSignState<TransactionData>> {
val data = method.transaction.decodeBase58() ?: byteArrayOf()
val transactionData = TransactionData.Compiled(
value = TransactionData.Compiled.Data.Bytes(data),
)
return delegate.invoke(transactionData)
}
private fun String.formatAsSolanaSignature(): String {
return "{ signature: \"${this}\" }"
}
private fun ByteArray.formatAsSolanaSignature(): String {
return "{ signature: \"${this.encodeBase58()}\" }"
}
private fun TransactionData.getTxHashFromCompiled(): ByteArray {
return when (this) {
is TransactionData.Compiled -> (value as? TransactionData.Compiled.Data.Bytes)?.data
?: error("Invalid transaction data")
is TransactionData.Uncompiled -> error("Transaction must be compiled")
}
}
private fun isLargeHash(hash: ByteArray): Boolean {
return hash.size > SOLANA_TRANSACTION_SIZE_THRESHOLD_BYTES
}
private fun getFormattedHash(hash: ByteArray): ByteArray {
return try {
SolanaTransactionHelper.removeSignaturesPlaceholders(hash)
} catch (e: Exception) {
TangemLogger.e("Failed to format the hash: ${e.message}")
hash
}
}
override fun isMultipleSignRequired(): Boolean {
val data = method.transaction.decodeBase58() ?: byteArrayOf()
return isLargeHash(data)
}
@AssistedFactory
interface Factory {
fun create(
context: WcMethodUseCaseContext,
method: WcSolanaMethod.SignAndSendTransaction,
): WcSolanaSignAndSendTransactionUseCase
}
}

View file

@ -200,20 +200,33 @@ internal class DefaultWcPairUseCase @AssistedInject constructor(
verifyContext: Wallet.Model.VerifyContext,
): Either<WcPairError, WcPairState.Proposal> = runCatching {
val proposalAccountNetwork = associateNetworksDelegate.associateAccounts(sessionProposal)
// Display URL: shown to the user and logged to analytics. Reown's verified origin when
// present, otherwise its `verify.walletconnect.org` fallback. NOT trustworthy for
// security checks: when validation is INVALID, getDappOriginUrl returns the dApp-claimed
// origin (so the UI can show what was claimed), which a scam dApp can spoof.
val displayUrl = verifyContext.getDappOriginUrl()
val verificationInfo = when {
verifyContext.validation == Wallet.Model.Validation.INVALID -> CheckDAppResult.UNSAFE
verifyContext.isScam == true -> CheckDAppResult.UNSAFE
else -> blockAidVerifier.verifyDApp(DAppData(sessionProposal.url)).getOrElse { error ->
TangemLogger.withTag(WC_TAG).e("Failed to verify DApp ${sessionProposal.name}", error)
CheckDAppResult.FAILED_TO_VERIFY
// BlockAid is scanned only against the Reown-verified origin (validation == VALID
// guarantees Reown confirmed origin matches the dApp's registered domain).
// For UNKNOWN we have no trustworthy URL: passing a dApp-claimed URL would let an
// impersonator (e.g. a scam claiming metadata.url=dydx.trade) inherit its target's
// BlockAid verdict.
verifyContext.validation == Wallet.Model.Validation.VALID -> {
blockAidVerifier.verifyDApp(DAppData(verifyContext.origin)).getOrElse { error ->
TangemLogger.withTag(WC_TAG).e("Failed to verify DApp ${sessionProposal.name}", error)
CheckDAppResult.FAILED_TO_VERIFY
}
}
else -> CheckDAppResult.FAILED_TO_VERIFY
}
val requestedNetworks = proposalAccountNetwork
.values.map { it.available.plus(it.required) }.flatten().toSet()
analytics.send(
WcAnalyticEvents.PairRequested(
dAppName = sessionProposal.name,
dAppUrl = sessionProposal.url,
dAppUrl = displayUrl,
network = requestedNetworks,
domainVerification = verificationInfo,
),
@ -221,7 +234,7 @@ internal class DefaultWcPairUseCase @AssistedInject constructor(
val appMetaData = WcAppMetaData(
name = sessionProposal.name,
description = sessionProposal.description,
url = sessionProposal.url,
url = displayUrl,
icons = sessionProposal.icons.map { it.toString() },
redirect = sessionProposal.redirect,
)

View file

@ -7,7 +7,6 @@ import com.reown.walletkit.client.Wallet
import com.reown.walletkit.client.WalletKit
import com.tangem.domain.walletconnect.WC_TAG
import com.tangem.data.walletconnect.utils.WcSdkObserver
import com.tangem.data.walletconnect.utils.getDappOriginUrl
import com.tangem.datasource.local.walletconnect.WalletConnectStore
import com.tangem.domain.walletconnect.model.WcPairError
import com.tangem.domain.walletconnect.model.WcPairError.ApprovalFailed
@ -110,9 +109,10 @@ internal class WcPairSdkDelegate(
sessionProposal: Wallet.Model.SessionProposal,
verifyContext: Wallet.Model.VerifyContext,
) {
val sessionProposalWithRealUrl = sessionProposal.copy(url = verifyContext.getDappOriginUrl())
// Triggered when wallet receives the session proposal sent by a Dapp
onSessionProposal.trySend(sessionProposalWithRealUrl to verifyContext)
// Triggered when wallet receives the session proposal sent by a Dapp.
// Pass the proposal through unchanged so consumers can decide between the dApp-claimed
// metadata url (sessionProposal.url) and the Verify-API origin (verifyContext.getDappOriginUrl()).
onSessionProposal.trySend(sessionProposal to verifyContext)
}
override fun onSessionSettleResponse(settleSessionResponse: Wallet.Model.SettledSessionResponse) {

View file

@ -53,6 +53,7 @@ internal class BlockAidVerificationDelegate @Inject constructor(
is WcEthMethod -> TransactionParams.Evm(rawSdkRequest.request.params)
is WcSolanaMethod.SignAllTransaction -> TransactionParams.Solana(method.transaction)
is WcSolanaMethod.SignTransaction -> TransactionParams.Solana(listOf(method.transaction))
is WcSolanaMethod.SignAndSendTransaction -> TransactionParams.Solana(listOf(method.transaction))
is WcSolanaMethod.SignMessage,
is WcBitcoinMethod,
-> {

View file

@ -147,6 +147,25 @@ internal class DefaultWcPairUseCaseTest {
}
}
@Test
fun `verifyDApp uses verifyContext origin when sessionProposal url is spoofed`() = runTest {
val spoofedProposal = sdkProposal.copy(url = "https://evil-spoofed.example/")
coEvery { sdkDelegate.pair(url) } returns (spoofedProposal to sdkVerifyContext).right()
coEvery { associateNetworksDelegate.associateAccounts(spoofedProposal) } returns mapOf()
coEvery { blockAidVerifier.verifyDApp(any()) } returns Either.catch { CheckDAppResult.SAFE }
val useCase = useCaseFactory()
useCase.invoke().test {
assertEquals(loading, awaitItem())
coVerifyOrder {
sdkDelegate.pair(url)
blockAidVerifier.verifyDApp(DAppData(sdkVerifyContext.origin))
}
assert(awaitItem() is WcPairState.Proposal)
expectNoEvents()
}
}
@Test
fun `success pair and approve flow`() = runTest {
val approveLoading = WcPairState.Approving.Loading(sessionForApprove)

View file

@ -43,6 +43,7 @@ dependencies {
kapt(deps.hilt.kapt)
/** Other */
implementation(deps.kotlin.datetime)
/** tests */
testImplementation(projects.common.test)

View file

@ -4,13 +4,18 @@ import com.tangem.core.analytics.api.AnalyticsExceptionHandler
import com.tangem.data.yield.supply.DefaultYieldSupplyRepository
import com.tangem.data.yield.supply.DefaultYieldSupplyErrorResolver
import com.tangem.data.yield.supply.DefaultYieldSupplyTransactionRepository
import com.tangem.data.yield.supply.promo.DefaultYieldPromoRepository
import com.tangem.datasource.api.tangemTech.TangemTechApi
import com.tangem.datasource.api.tangemTech.YieldSupplyApi
import com.tangem.datasource.local.preferences.AppPreferencesStore
import com.tangem.datasource.local.yieldsupply.YieldMarketsStore
import com.tangem.datasource.local.yieldsupply.promo.YieldBoostPromoStore
import com.tangem.datasource.local.yieldsupply.promo.YieldBoostStatusStore
import com.tangem.domain.walletmanager.WalletManagersFacade
import com.tangem.domain.yield.supply.YieldSupplyRepository
import com.tangem.domain.yield.supply.YieldSupplyErrorResolver
import com.tangem.domain.yield.supply.YieldSupplyTransactionRepository
import com.tangem.domain.yield.supply.promo.YieldPromoRepository
import com.tangem.utils.coroutines.CoroutineDispatcherProvider
import dagger.Module
import dagger.Provides
@ -59,4 +64,20 @@ internal object YieldSupplyDataModule {
fun provideYieldSupplyErrorResolver(): YieldSupplyErrorResolver {
return DefaultYieldSupplyErrorResolver
}
@Provides
@Singleton
fun provideYieldPromoRepository(
tangemApi: TangemTechApi,
promoStore: YieldBoostPromoStore,
statusStore: YieldBoostStatusStore,
dispatchers: CoroutineDispatcherProvider,
): YieldPromoRepository {
return DefaultYieldPromoRepository(
tangemApi = tangemApi,
promoStore = promoStore,
statusStore = statusStore,
dispatchers = dispatchers,
)
}
}

View file

@ -0,0 +1,63 @@
package com.tangem.data.yield.supply.promo
import com.tangem.data.yield.supply.promo.converter.YieldBoostPromoConverter
import com.tangem.data.yield.supply.promo.converter.YieldBoostStatusConverter
import com.tangem.datasource.api.common.response.getOrThrow
import com.tangem.datasource.api.tangemTech.TangemTechApi
import com.tangem.datasource.local.yieldsupply.promo.YieldBoostPromoStore
import com.tangem.datasource.local.yieldsupply.promo.YieldBoostStatusStore
import com.tangem.domain.models.wallet.UserWalletId
import com.tangem.domain.yield.supply.models.YieldBoostPromo
import com.tangem.domain.yield.supply.models.YieldBoostStatus
import com.tangem.domain.yield.supply.promo.YieldPromoRepository
import com.tangem.utils.coroutines.CoroutineDispatcherProvider
import kotlinx.coroutines.withContext
internal class DefaultYieldPromoRepository(
private val tangemApi: TangemTechApi,
private val promoStore: YieldBoostPromoStore,
private val statusStore: YieldBoostStatusStore,
private val dispatchers: CoroutineDispatcherProvider,
) : YieldPromoRepository {
override suspend fun getYieldBoostPromo(userWalletId: UserWalletId, forceRefresh: Boolean): YieldBoostPromo {
if (!forceRefresh) {
promoStore.getSyncOrNull(userWalletId)?.let { return it }
}
return try {
val fresh = fetchPromo(userWalletId)
promoStore.store(userWalletId, fresh)
fresh
} catch (e: Exception) {
promoStore.getSyncOrNull(userWalletId) ?: throw e
}
}
override suspend fun getYieldBoostStatus(userWalletId: UserWalletId, forceRefresh: Boolean): YieldBoostStatus {
if (!forceRefresh) {
statusStore.getSyncOrNull(userWalletId)?.let { return it }
}
return try {
val fresh = fetchStatus(userWalletId)
statusStore.store(userWalletId, fresh)
fresh
} catch (e: Exception) {
statusStore.getSyncOrNull(userWalletId) ?: throw e
}
}
private suspend fun fetchPromo(userWalletId: UserWalletId): YieldBoostPromo = withContext(dispatchers.io) {
val response = tangemApi.getPromotions(walletId = userWalletId.stringValue).getOrThrow()
val dto = response.promotions.firstOrNull { it.name == PROMO_NAME } ?: return@withContext YieldBoostPromo.None
YieldBoostPromoConverter.convert(dto)
}
private suspend fun fetchStatus(userWalletId: UserWalletId): YieldBoostStatus = withContext(dispatchers.io) {
val response = tangemApi.getYieldBoostStatus(walletId = userWalletId.stringValue).getOrThrow()
YieldBoostStatusConverter.convert(response)
}
private companion object {
const val PROMO_NAME = "yield-apr-boost"
}
}

View file

@ -0,0 +1,34 @@
package com.tangem.data.yield.supply.promo.converter
import com.tangem.datasource.api.promotion.models.PromotionsResponse
import com.tangem.domain.yield.supply.models.YieldBoostPromo
import kotlinx.datetime.Instant
internal object YieldBoostPromoConverter {
private const val ACTIVE_STATUS = "active"
fun convert(dto: PromotionsResponse.PromotionDto): YieldBoostPromo {
val all = dto.all ?: return YieldBoostPromo.None
if (!all.status.equals(ACTIVE_STATUS, ignoreCase = true)) return YieldBoostPromo.None
val start = runCatching { Instant.parse(all.timeline.start) }.getOrNull() ?: return YieldBoostPromo.None
val end = runCatching { Instant.parse(all.timeline.end) }.getOrNull() ?: return YieldBoostPromo.None
val tokens = all.tokens.orEmpty().map { token ->
YieldBoostPromo.Active.PromoToken(
contractAddress = token.tokenAddress,
tokenSymbol = token.tokenSymbol,
tokenName = token.tokenName,
networkId = token.networkId,
)
}
if (tokens.isEmpty()) return YieldBoostPromo.None
return YieldBoostPromo.Active(
tokens = tokens,
timeline = YieldBoostPromo.Active.Timeline(start = start, end = end),
link = all.link,
)
}
}

View file

@ -0,0 +1,46 @@
package com.tangem.data.yield.supply.promo.converter
import com.tangem.datasource.api.promotion.models.YieldBoostStatusResponse
import com.tangem.domain.yield.supply.models.YieldBoostStatus
import kotlinx.datetime.Instant
internal object YieldBoostStatusConverter {
private const val STATUS_NOT_STARTED = "notstarted"
private const val STATUS_ACTIVE = "active"
private const val STATUS_COMPLETED = "completed"
private const val STATUS_DISQUALIFIED = "disqualified"
private const val REASON_FROD = "frod"
private const val REASON_LESS_THAN_1_USD = "less1usd"
private const val REASON_CLOSED = "closed"
fun convert(dto: YieldBoostStatusResponse): YieldBoostStatus = when (dto.promoEnrollmentStatus.lowercase()) {
STATUS_ACTIVE, STATUS_COMPLETED -> dto.toEnrolled()
STATUS_DISQUALIFIED -> YieldBoostStatus.Disqualified(reason = dto.disqualificationReason.toReason())
STATUS_NOT_STARTED -> YieldBoostStatus.NotStarted
else -> YieldBoostStatus.NotStarted // forward-compat: unknown status → treat as NotStarted
}
/**
* Backend `"active"` / `"completed"` [YieldBoostStatus.Enrolled].
*
* An unparseable / missing `qualificationEndDate` is kept as `null` (block hidden) never downgraded to
* [YieldBoostStatus.NotStarted], which would re-prompt an already-enrolled user to join.
*/
private fun YieldBoostStatusResponse.toEnrolled(): YieldBoostStatus.Enrolled = YieldBoostStatus.Enrolled(
tokenName = tokenName.orEmpty(),
networkId = networkId.orEmpty(),
moduleAddress = moduleAddress.orEmpty(),
userAddress = userAddress.orEmpty(),
contractAddress = contractAddress.orEmpty(),
qualificationEndDate = qualificationEndDate?.let { runCatching { Instant.parse(it) }.getOrNull() },
)
private fun String?.toReason(): YieldBoostStatus.Disqualified.Reason = when (this?.lowercase()) {
REASON_FROD -> YieldBoostStatus.Disqualified.Reason.FROD
REASON_LESS_THAN_1_USD -> YieldBoostStatus.Disqualified.Reason.LESS_THAN_1_USD
REASON_CLOSED -> YieldBoostStatus.Disqualified.Reason.CLOSED
else -> YieldBoostStatus.Disqualified.Reason.UNKNOWN
}
}

View file

@ -0,0 +1,119 @@
package com.tangem.data.yield.supply.promo.converter
import com.google.common.truth.Truth.assertThat
import com.tangem.datasource.api.promotion.models.PromotionsResponse
import com.tangem.domain.yield.supply.models.YieldBoostPromo
import org.junit.jupiter.api.Test
class YieldBoostPromoConverterTest {
@Test
fun `GIVEN active dto with tokens WHEN convert THEN returns Active`() {
val dto = activeDto()
val result = YieldBoostPromoConverter.convert(dto)
assertThat(result).isInstanceOf(YieldBoostPromo.Active::class.java)
val active = result as YieldBoostPromo.Active
assertThat(active.tokens).hasSize(2)
assertThat(active.tokens.first().contractAddress)
.isEqualTo("0xa0b86991c6218b36c1d19d4a2e9eb0ce3606eb48")
assertThat(active.tokens.first().networkId).isEqualTo("ethereum")
assertThat(active.link).isEqualTo("https://example.com/terms")
}
@Test
fun `GIVEN dto with null all WHEN convert THEN returns None`() {
val dto = PromotionsResponse.PromotionDto(name = "promo-yield-apr-boost", all = null)
val result = YieldBoostPromoConverter.convert(dto)
assertThat(result).isEqualTo(YieldBoostPromo.None)
}
@Test
fun `GIVEN dto with non-active status WHEN convert THEN returns None`() {
val dto = activeDto().copy(
all = activeDto().all!!.copy(status = "expired"),
)
val result = YieldBoostPromoConverter.convert(dto)
assertThat(result).isEqualTo(YieldBoostPromo.None)
}
@Test
fun `GIVEN dto with empty tokens WHEN convert THEN returns None`() {
val dto = activeDto().copy(
all = activeDto().all!!.copy(tokens = emptyList()),
)
val result = YieldBoostPromoConverter.convert(dto)
assertThat(result).isEqualTo(YieldBoostPromo.None)
}
@Test
fun `GIVEN dto with null tokens WHEN convert THEN returns None`() {
val dto = activeDto().copy(
all = activeDto().all!!.copy(tokens = null),
)
val result = YieldBoostPromoConverter.convert(dto)
assertThat(result).isEqualTo(YieldBoostPromo.None)
}
@Test
fun `GIVEN dto with malformed start date WHEN convert THEN returns None`() {
val dto = activeDto().copy(
all = activeDto().all!!.copy(
timeline = PromotionsResponse.PromotionDto.Timeline(
start = "not-an-iso",
end = "2027-06-15T22:00:00.000Z",
),
),
)
val result = YieldBoostPromoConverter.convert(dto)
assertThat(result).isEqualTo(YieldBoostPromo.None)
}
@Test
fun `GIVEN status with uppercase casing WHEN convert THEN treats as active`() {
val dto = activeDto().copy(
all = activeDto().all!!.copy(status = "ACTIVE"),
)
val result = YieldBoostPromoConverter.convert(dto)
assertThat(result).isInstanceOf(YieldBoostPromo.Active::class.java)
}
private fun activeDto() = PromotionsResponse.PromotionDto(
name = "promo-yield-apr-boost",
all = PromotionsResponse.PromotionDto.All(
timeline = PromotionsResponse.PromotionDto.Timeline(
start = "2026-06-15T00:00:00.000Z",
end = "2027-06-15T22:00:00.000Z",
),
tokens = listOf(
PromotionsResponse.PromotionDto.PromoToken(
tokenAddress = "0xa0b86991c6218b36c1d19d4a2e9eb0ce3606eb48",
tokenSymbol = "USDC",
tokenName = "USD Coin",
networkId = "ethereum",
),
PromotionsResponse.PromotionDto.PromoToken(
tokenAddress = "0xdac17f958d2ee523a2206206994597c13d831ec7",
tokenSymbol = "USDT",
tokenName = "Tether USD",
networkId = "ethereum",
),
),
status = "active",
link = "https://example.com/terms",
),
)
}

View file

@ -0,0 +1,186 @@
package com.tangem.data.yield.supply.promo.converter
import com.google.common.truth.Truth.assertThat
import com.tangem.datasource.api.promotion.models.YieldBoostStatusResponse
import com.tangem.domain.yield.supply.models.YieldBoostStatus
import kotlinx.datetime.Instant
import org.junit.jupiter.api.Test
class YieldBoostStatusConverterTest {
private val qualificationEnd = "2026-06-01T00:00:00Z"
@Test
fun `GIVEN promoEnrollmentStatus notStarted WHEN convert THEN returns NotStarted`() {
val dto = dto(promoEnrollmentStatus = "notStarted")
val result = YieldBoostStatusConverter.convert(dto)
assertThat(result).isEqualTo(YieldBoostStatus.NotStarted)
}
@Test
fun `GIVEN active backend status with valid date WHEN convert THEN returns Enrolled`() {
val dto = dto(
promoEnrollmentStatus = "active",
tokenName = "USD Coin",
networkId = "ethereum",
moduleAddress = "0xmodule",
userAddress = "0xuser",
contractAddress = "0xcontract",
qualificationEndDate = qualificationEnd,
)
val result = YieldBoostStatusConverter.convert(dto)
assertThat(result).isInstanceOf(YieldBoostStatus.Enrolled::class.java)
val enrolled = result as YieldBoostStatus.Enrolled
assertThat(enrolled.tokenName).isEqualTo("USD Coin")
assertThat(enrolled.networkId).isEqualTo("ethereum")
assertThat(enrolled.contractAddress).isEqualTo("0xcontract")
assertThat(enrolled.qualificationEndDate).isEqualTo(Instant.parse(qualificationEnd))
}
@Test
fun `GIVEN active status missing qualificationEndDate WHEN convert THEN returns Enrolled with null date`() {
val dto = dto(
promoEnrollmentStatus = "active",
contractAddress = "0xcontract",
qualificationEndDate = null,
)
val result = YieldBoostStatusConverter.convert(dto)
assertThat(result).isInstanceOf(YieldBoostStatus.Enrolled::class.java)
assertThat((result as YieldBoostStatus.Enrolled).qualificationEndDate).isNull()
}
@Test
fun `GIVEN active status with malformed qualificationEndDate WHEN convert THEN returns Enrolled with null date`() {
val dto = dto(
promoEnrollmentStatus = "active",
contractAddress = "0xcontract",
qualificationEndDate = "not-an-iso",
)
val result = YieldBoostStatusConverter.convert(dto)
assertThat(result).isInstanceOf(YieldBoostStatus.Enrolled::class.java)
assertThat((result as YieldBoostStatus.Enrolled).qualificationEndDate).isNull()
}
@Test
fun `GIVEN completed status with valid date WHEN convert THEN returns Enrolled`() {
val dto = dto(
promoEnrollmentStatus = "completed",
tokenName = "USDT",
networkId = "ethereum",
moduleAddress = "0xmodule",
userAddress = "0xuser",
contractAddress = "0xcontract",
qualificationEndDate = "2026-05-01T00:00:00Z",
)
val result = YieldBoostStatusConverter.convert(dto)
assertThat(result).isInstanceOf(YieldBoostStatus.Enrolled::class.java)
assertThat((result as YieldBoostStatus.Enrolled).qualificationEndDate)
.isEqualTo(Instant.parse("2026-05-01T00:00:00Z"))
}
@Test
fun `GIVEN disqualified frod reason WHEN convert THEN returns Disqualified with FROD reason`() {
val dto = dto(
promoEnrollmentStatus = "disqualified",
disqualificationReason = "frod",
)
val result = YieldBoostStatusConverter.convert(dto)
assertThat(result).isEqualTo(YieldBoostStatus.Disqualified(YieldBoostStatus.Disqualified.Reason.FROD))
}
@Test
fun `GIVEN disqualified less1usd reason WHEN convert THEN returns Disqualified with LESS_THAN_1_USD reason`() {
val dto = dto(
promoEnrollmentStatus = "disqualified",
disqualificationReason = "less1usd",
)
val result = YieldBoostStatusConverter.convert(dto)
assertThat(result).isEqualTo(
YieldBoostStatus.Disqualified(YieldBoostStatus.Disqualified.Reason.LESS_THAN_1_USD),
)
}
@Test
fun `GIVEN disqualified closed reason WHEN convert THEN returns Disqualified with CLOSED reason`() {
val dto = dto(
promoEnrollmentStatus = "disqualified",
disqualificationReason = "closed",
)
val result = YieldBoostStatusConverter.convert(dto)
assertThat(result).isEqualTo(YieldBoostStatus.Disqualified(YieldBoostStatus.Disqualified.Reason.CLOSED))
}
@Test
fun `GIVEN disqualified unknown reason WHEN convert THEN returns Disqualified with UNKNOWN reason`() {
val dto = dto(
promoEnrollmentStatus = "disqualified",
disqualificationReason = "alien_invasion",
)
val result = YieldBoostStatusConverter.convert(dto)
assertThat(result).isEqualTo(YieldBoostStatus.Disqualified(YieldBoostStatus.Disqualified.Reason.UNKNOWN))
}
@Test
fun `GIVEN unknown promoEnrollmentStatus WHEN convert THEN returns NotStarted`() {
val dto = dto(promoEnrollmentStatus = "futureBackendStatus")
val result = YieldBoostStatusConverter.convert(dto)
assertThat(result).isEqualTo(YieldBoostStatus.NotStarted)
}
@Test
fun `GIVEN status with uppercase casing WHEN convert THEN normalizes correctly`() {
val dto = dto(
promoEnrollmentStatus = "ACTIVE",
tokenName = "USDT",
networkId = "ethereum",
moduleAddress = "0xmodule",
userAddress = "0xuser",
contractAddress = "0xcontract",
qualificationEndDate = qualificationEnd,
)
val result = YieldBoostStatusConverter.convert(dto)
assertThat(result).isInstanceOf(YieldBoostStatus.Enrolled::class.java)
}
private fun dto(
promoEnrollmentStatus: String,
tokenName: String? = null,
networkId: String? = null,
moduleAddress: String? = null,
userAddress: String? = null,
contractAddress: String? = null,
qualificationEndDate: String? = null,
disqualificationReason: String? = null,
) = YieldBoostStatusResponse(
tokenName = tokenName,
networkId = networkId,
moduleAddress = moduleAddress,
userAddress = userAddress,
contractAddress = contractAddress,
promoEnrollmentStatus = promoEnrollmentStatus,
qualificationEndDate = qualificationEndDate,
disqualificationReason = disqualificationReason,
)
}