Updated on 2026-08-14
This commit is contained in:
commit
6b9fc4a3ce
1058 changed files with 48197 additions and 12718 deletions
|
|
@ -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"
|
||||
}
|
||||
}
|
||||
|
|
@ -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
|
||||
|
|
|
|||
20
data/appsflyer/build.gradle.kts
Normal file
20
data/appsflyer/build.gradle.kts
Normal 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)
|
||||
}
|
||||
|
|
@ -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
|
||||
}
|
||||
}
|
||||
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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) {
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
}
|
||||
}
|
||||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
}
|
||||
|
|
@ -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),
|
||||
),
|
||||
),
|
||||
)
|
||||
}
|
||||
}
|
||||
39
data/push-notification-preferences/build.gradle.kts
Normal file
39
data/push-notification-preferences/build.gradle.kts
Normal 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)
|
||||
}
|
||||
|
|
@ -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),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
|
@ -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)
|
||||
}
|
||||
|
|
@ -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,
|
||||
)
|
||||
}
|
||||
|
|
@ -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),
|
||||
)
|
||||
}
|
||||
|
|
@ -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()
|
||||
}
|
||||
}
|
||||
|
|
@ -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,
|
||||
)
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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,
|
||||
)
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
|
|
@ -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()
|
||||
}
|
||||
}
|
||||
|
|
@ -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)
|
||||
|
|
@ -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
|
||||
}
|
||||
}
|
||||
|
|
@ -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> {
|
||||
|
|
@ -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,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
}
|
||||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
|
|
@ -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
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
}
|
||||
|
|
@ -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(),
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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")
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
}
|
||||
|
|
@ -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,
|
||||
)
|
||||
|
|
|
|||
|
|
@ -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) {
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
-> {
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -43,6 +43,7 @@ dependencies {
|
|||
kapt(deps.hilt.kapt)
|
||||
|
||||
/** Other */
|
||||
implementation(deps.kotlin.datetime)
|
||||
|
||||
/** tests */
|
||||
testImplementation(projects.common.test)
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
|
@ -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"
|
||||
}
|
||||
}
|
||||
|
|
@ -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,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
|
@ -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
|
||||
}
|
||||
}
|
||||
|
|
@ -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",
|
||||
),
|
||||
)
|
||||
}
|
||||
|
|
@ -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,
|
||||
)
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue