Updated on 2026-08-14
This commit is contained in:
commit
bfd903b43c
595 changed files with 12755 additions and 19837 deletions
|
|
@ -18,7 +18,7 @@ import com.tangem.datasource.appcurrency.AppCurrencyResponseStore
|
|||
import com.tangem.datasource.crypto.DataSignatureVerifier
|
||||
import com.tangem.datasource.di.NetworkMoshi
|
||||
import com.tangem.datasource.exchangeservice.hotcrypto.HotCryptoResponseStore
|
||||
import com.tangem.datasource.local.config.environment.EnvironmentConfigStorage
|
||||
import com.tangem.datasource.local.config.environment.EnvironmentConfig
|
||||
import com.tangem.datasource.local.onramp.countries.OnrampCountriesStore
|
||||
import com.tangem.datasource.local.onramp.currencies.OnrampCurrenciesStore
|
||||
import com.tangem.datasource.local.onramp.pairs.OnrampPairsStore
|
||||
|
|
@ -131,11 +131,11 @@ internal object OnrampDataModule {
|
|||
@Provides
|
||||
@Singleton
|
||||
fun provideMercuryoRepository(
|
||||
environmentConfigStorage: EnvironmentConfigStorage,
|
||||
environmentConfig: EnvironmentConfig,
|
||||
dispatchersProvider: CoroutineDispatcherProvider,
|
||||
): LegacyTopUpRepository {
|
||||
return MercuryoTopUpRepository(
|
||||
environmentConfigStorage = environmentConfigStorage,
|
||||
environmentConfig = environmentConfig,
|
||||
dispatchersProvider = dispatchersProvider,
|
||||
)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -5,7 +5,6 @@ import com.tangem.blockchainsdk.utils.toBlockchain
|
|||
import com.tangem.common.extensions.calculateSha512
|
||||
import com.tangem.common.extensions.toHexString
|
||||
import com.tangem.datasource.local.config.environment.EnvironmentConfig
|
||||
import com.tangem.datasource.local.config.environment.EnvironmentConfigStorage
|
||||
import com.tangem.domain.models.currency.CryptoCurrency
|
||||
import com.tangem.domain.onramp.repositories.LegacyTopUpRepository
|
||||
import com.tangem.utils.coroutines.CoroutineDispatcherProvider
|
||||
|
|
@ -13,14 +12,13 @@ import kotlinx.coroutines.withContext
|
|||
import javax.inject.Inject
|
||||
|
||||
internal class MercuryoTopUpRepository @Inject constructor(
|
||||
private val environmentConfigStorage: EnvironmentConfigStorage,
|
||||
private val environmentConfig: EnvironmentConfig,
|
||||
private val dispatchersProvider: CoroutineDispatcherProvider,
|
||||
) : LegacyTopUpRepository {
|
||||
|
||||
override suspend fun getTopUpUrl(cryptoCurrency: CryptoCurrency, walletAddress: String): String =
|
||||
withContext(dispatchersProvider.default) {
|
||||
val blockchain = cryptoCurrency.network.toBlockchain()
|
||||
val environmentConfig = environmentConfigStorage.getConfigSync()
|
||||
|
||||
val builder = Uri.Builder()
|
||||
.scheme(LegacyTopUpRepository.SCHEME)
|
||||
|
|
|
|||
|
|
@ -49,7 +49,9 @@ import com.tangem.domain.walletmanager.WalletManagersFacade
|
|||
import com.tangem.utils.coroutines.CoroutineDispatcherProvider
|
||||
import kotlinx.coroutines.async
|
||||
import kotlinx.coroutines.awaitAll
|
||||
import kotlinx.coroutines.flow.*
|
||||
import kotlinx.coroutines.flow.Flow
|
||||
import kotlinx.coroutines.flow.distinctUntilChanged
|
||||
import kotlinx.coroutines.flow.map
|
||||
import kotlinx.coroutines.withContext
|
||||
import timber.log.Timber
|
||||
|
||||
|
|
@ -161,10 +163,6 @@ internal class DefaultStakeKitRepository(
|
|||
|
||||
private fun getAvailableStakeKitIntegrationsIds(): List<StakingIntegrationID.StakeKit> {
|
||||
return StakingIntegrationID.StakeKit.entries
|
||||
// load all integrations for now and filter in use cases if needed
|
||||
// .filterNot {
|
||||
// it.blockchain == Blockchain.Cardano && !stakingFeatureToggles.isCardanoStakingEnabled
|
||||
// }
|
||||
}
|
||||
|
||||
private fun NetworkTypeDTO.extractJsonName(): String {
|
||||
|
|
|
|||
|
|
@ -8,7 +8,6 @@ import com.tangem.domain.card.common.TapWorkarounds.isWallet2
|
|||
import com.tangem.domain.models.currency.CryptoCurrency
|
||||
import com.tangem.domain.models.scan.ProductType
|
||||
import com.tangem.domain.models.staking.StakingBalance
|
||||
import com.tangem.domain.models.staking.StakingID
|
||||
import com.tangem.domain.models.wallet.UserWallet
|
||||
import com.tangem.domain.models.wallet.UserWalletId
|
||||
import com.tangem.domain.staking.model.StakingAvailability
|
||||
|
|
@ -17,7 +16,6 @@ import com.tangem.domain.staking.repositories.P2PEthPoolRepository
|
|||
import com.tangem.domain.staking.repositories.StakeKitRepository
|
||||
import com.tangem.domain.staking.repositories.StakingRepository
|
||||
import com.tangem.domain.staking.toggles.StakingFeatureToggles
|
||||
import com.tangem.domain.walletmanager.WalletManagersFacade
|
||||
import com.tangem.domain.wallets.usecase.GetUserWalletUseCase
|
||||
import com.tangem.lib.crypto.BlockchainUtils.isCardano
|
||||
import com.tangem.lib.crypto.BlockchainUtils.isSolana
|
||||
|
|
@ -35,7 +33,6 @@ internal class DefaultStakingRepository(
|
|||
private val dispatchers: CoroutineDispatcherProvider,
|
||||
private val getUserWalletUseCase: GetUserWalletUseCase,
|
||||
private val stakingFeatureToggles: StakingFeatureToggles,
|
||||
private val walletManagersFacade: WalletManagersFacade,
|
||||
) : StakingRepository {
|
||||
|
||||
override fun getStakingAvailability(
|
||||
|
|
@ -43,7 +40,7 @@ internal class DefaultStakingRepository(
|
|||
cryptoCurrency: CryptoCurrency,
|
||||
): Flow<StakingAvailability> {
|
||||
return channelFlow {
|
||||
if (!checkFeatureToggleEnabled(userWalletId, cryptoCurrency)) {
|
||||
if (!checkFeatureToggleEnabled(cryptoCurrency)) {
|
||||
send(StakingAvailability.Unavailable)
|
||||
return@channelFlow
|
||||
}
|
||||
|
|
@ -79,7 +76,7 @@ internal class DefaultStakingRepository(
|
|||
userWalletId: UserWalletId,
|
||||
cryptoCurrency: CryptoCurrency,
|
||||
): StakingAvailability {
|
||||
if (!checkFeatureToggleEnabled(userWalletId, cryptoCurrency)) {
|
||||
if (!checkFeatureToggleEnabled(cryptoCurrency)) {
|
||||
return StakingAvailability.Unavailable
|
||||
}
|
||||
|
||||
|
|
@ -119,31 +116,14 @@ internal class DefaultStakingRepository(
|
|||
}
|
||||
}
|
||||
|
||||
private suspend fun checkFeatureToggleEnabled(userWalletId: UserWalletId, cryptoCurrency: CryptoCurrency): Boolean {
|
||||
private fun checkFeatureToggleEnabled(cryptoCurrency: CryptoCurrency): Boolean {
|
||||
return when (cryptoCurrency.network.id.toBlockchain()) {
|
||||
Blockchain.TON -> stakingFeatureToggles.isTonStakingEnabled
|
||||
Blockchain.Ethereum -> {
|
||||
when (cryptoCurrency) {
|
||||
is CryptoCurrency.Coin -> stakingFeatureToggles.isEthStakingEnabled
|
||||
is CryptoCurrency.Token -> true
|
||||
}
|
||||
}
|
||||
Blockchain.Cardano -> {
|
||||
val address = walletManagersFacade.getDefaultAddress(userWalletId, cryptoCurrency.network).orEmpty()
|
||||
val balance = stakingBalanceStoreV2.getSyncOrNull(
|
||||
userWalletId = userWalletId,
|
||||
stakingId = StakingID(
|
||||
integrationId = StakingIntegrationID.create(currencyId = cryptoCurrency.id)?.value
|
||||
?: return false,
|
||||
address = address,
|
||||
),
|
||||
)
|
||||
if ((balance as? StakingBalance.Data.StakeKit)?.balance?.items?.isNotEmpty() == true) {
|
||||
return true
|
||||
} else {
|
||||
stakingFeatureToggles.isCardanoStakingEnabled
|
||||
}
|
||||
}
|
||||
else -> true
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -61,7 +61,6 @@ internal object StakingDataModule {
|
|||
dispatchers: CoroutineDispatcherProvider,
|
||||
getUserWalletUseCase: GetUserWalletUseCase,
|
||||
stakingFeatureToggles: StakingFeatureToggles,
|
||||
walletManagersFacade: WalletManagersFacade,
|
||||
): StakingRepository {
|
||||
return DefaultStakingRepository(
|
||||
stakeKitRepository = stakeKitRepository,
|
||||
|
|
@ -69,7 +68,6 @@ internal object StakingDataModule {
|
|||
stakingBalanceStoreV2 = stakeKitBalancesStore,
|
||||
dispatchers = dispatchers,
|
||||
getUserWalletUseCase = getUserWalletUseCase,
|
||||
walletManagersFacade = walletManagersFacade,
|
||||
stakingFeatureToggles = stakingFeatureToggles,
|
||||
)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -7,12 +7,6 @@ internal class DefaultStakingFeatureToggles(
|
|||
private val featureTogglesManager: FeatureTogglesManager,
|
||||
) : StakingFeatureToggles {
|
||||
|
||||
override val isTonStakingEnabled: Boolean
|
||||
get() = featureTogglesManager.isFeatureEnabled(name = "STAKING_TON_ENABLED")
|
||||
|
||||
override val isCardanoStakingEnabled: Boolean
|
||||
get() = featureTogglesManager.isFeatureEnabled("STAKING_CARDANO_ENABLED")
|
||||
|
||||
override val isEthStakingEnabled: Boolean
|
||||
get() = featureTogglesManager.isFeatureEnabled("STAKING_ETH_ENABLED")
|
||||
}
|
||||
|
|
@ -38,11 +38,6 @@ dependencies {
|
|||
implementation(projects.domain.common)
|
||||
implementation(projects.features.swap.domain)
|
||||
|
||||
/** Feature API - remove after removing [HotWalletFeatureToggles] */
|
||||
implementation(projects.features.hotWallet.api)
|
||||
|
||||
/** Feature API - remove after removing [TangemPayFeatureToggles] */
|
||||
implementation(projects.features.tangempay.details.api)
|
||||
|
||||
/** Project - Utils */
|
||||
implementation(projects.core.utils)
|
||||
|
|
@ -53,6 +48,7 @@ dependencies {
|
|||
implementation(projects.libs.visa)
|
||||
|
||||
/** Libs - Other */
|
||||
implementation(deps.androidx.datastore)
|
||||
implementation(deps.kotlin.coroutines)
|
||||
implementation(deps.arrow.core)
|
||||
implementation(deps.arrow.fx)
|
||||
|
|
@ -70,6 +66,6 @@ dependencies {
|
|||
implementation(projects.libs.tangemSdkApi)
|
||||
|
||||
/** DI */
|
||||
implementation(deps.hilt.core)
|
||||
implementation(deps.hilt.android)
|
||||
kapt(deps.hilt.kapt)
|
||||
}
|
||||
|
|
@ -104,7 +104,7 @@ internal class DefaultTangemPayEligibilityManager @Inject constructor(
|
|||
map { wallet ->
|
||||
async {
|
||||
val isCustomer = onboardingRepository
|
||||
.checkCustomerWallet(wallet.walletId)
|
||||
.hasTangemPayInWallet(wallet.walletId)
|
||||
.getOrNull() == true
|
||||
wallet to isCustomer
|
||||
}
|
||||
|
|
|
|||
|
|
@ -0,0 +1,67 @@
|
|||
package com.tangem.data.pay.converter
|
||||
|
||||
import com.tangem.data.pay.converter.PaymentAccountStatusDMConverter.convert
|
||||
import com.tangem.data.pay.converter.PaymentAccountStatusDMConverter.convertBack
|
||||
import com.tangem.datasource.local.visa.entity.PaymentAccountStatusDM
|
||||
import com.tangem.domain.models.StatusSource
|
||||
import com.tangem.domain.pay.PaymentAccountStatus
|
||||
import com.tangem.utils.converter.TwoWayConverter
|
||||
|
||||
/**
|
||||
* Two-way converter between [PaymentAccountStatus] and [PaymentAccountStatusDM].
|
||||
*
|
||||
* [convert] maps domain → data model. Returns null for transient statuses that should not be persisted
|
||||
* (Loading, ExposedDevice, Unavailable, NotSynced).
|
||||
*
|
||||
* [convertBack] maps data model → domain. All restored statuses have [StatusSource.CACHE] as source.
|
||||
*/
|
||||
internal object PaymentAccountStatusDMConverter :
|
||||
TwoWayConverter<PaymentAccountStatus, PaymentAccountStatusDM?> {
|
||||
|
||||
override fun convert(value: PaymentAccountStatus): PaymentAccountStatusDM? {
|
||||
return when (value) {
|
||||
is PaymentAccountStatus.NotCreated -> PaymentAccountStatusDM.NotCreated()
|
||||
is PaymentAccountStatus.UnderReview -> PaymentAccountStatusDM.UnderReview(kycStatus = value.kycStatus)
|
||||
is PaymentAccountStatus.IssuingCard -> PaymentAccountStatusDM.IssuingCard()
|
||||
is PaymentAccountStatus.Locked -> PaymentAccountStatusDM.Locked()
|
||||
is PaymentAccountStatus.Loaded -> PaymentAccountStatusDM.Loaded(
|
||||
cardId = value.cardId,
|
||||
lastFourDigits = value.lastFourDigits,
|
||||
balance = value.balance,
|
||||
currencyCode = value.currencyCode,
|
||||
depositAddress = value.depositAddress,
|
||||
isPinSet = value.isPinSet,
|
||||
)
|
||||
is PaymentAccountStatus.Error.CardIssueFailed -> PaymentAccountStatusDM.CardIssueFailed()
|
||||
// Transient statuses are not persisted
|
||||
is PaymentAccountStatus.Loading,
|
||||
is PaymentAccountStatus.Error.ExposedDevice,
|
||||
is PaymentAccountStatus.Error.Unavailable,
|
||||
is PaymentAccountStatus.Error.NotSynced,
|
||||
-> null
|
||||
}
|
||||
}
|
||||
|
||||
override fun convertBack(value: PaymentAccountStatusDM?): PaymentAccountStatus {
|
||||
return when (value) {
|
||||
is PaymentAccountStatusDM.CardIssueFailed -> PaymentAccountStatus.Error.CardIssueFailed
|
||||
is PaymentAccountStatusDM.NotCreated -> PaymentAccountStatus.NotCreated
|
||||
is PaymentAccountStatusDM.IssuingCard -> PaymentAccountStatus.IssuingCard(source = StatusSource.CACHE)
|
||||
is PaymentAccountStatusDM.Locked -> PaymentAccountStatus.Locked(source = StatusSource.CACHE)
|
||||
is PaymentAccountStatusDM.UnderReview -> PaymentAccountStatus.UnderReview(
|
||||
source = StatusSource.CACHE,
|
||||
kycStatus = value.kycStatus,
|
||||
)
|
||||
is PaymentAccountStatusDM.Loaded -> PaymentAccountStatus.Loaded(
|
||||
source = StatusSource.CACHE,
|
||||
cardId = value.cardId,
|
||||
lastFourDigits = value.lastFourDigits,
|
||||
balance = value.balance,
|
||||
currencyCode = value.currencyCode,
|
||||
depositAddress = value.depositAddress,
|
||||
isPinSet = value.isPinSet,
|
||||
)
|
||||
null -> PaymentAccountStatus.Error.Unavailable(source = StatusSource.CACHE)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -1,13 +1,28 @@
|
|||
package com.tangem.data.pay.di
|
||||
|
||||
import android.content.Context
|
||||
import androidx.datastore.core.DataStoreFactory
|
||||
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.flow.DefaultPaymentAccountStatusFetcher
|
||||
import com.tangem.data.pay.flow.DefaultPaymentAccountStatusProducer
|
||||
import com.tangem.data.pay.repository.*
|
||||
import com.tangem.data.pay.store.PaymentAccountStatusesStore
|
||||
import com.tangem.data.pay.usecase.DefaultGetTangemPayCurrencyStatusUseCase
|
||||
import com.tangem.data.pay.usecase.DefaultGetTangemPayCustomerIdUseCase
|
||||
import com.tangem.data.pay.usecase.DefaultTangemPayWithdrawUseCase
|
||||
import com.tangem.datasource.di.NetworkMoshi
|
||||
import com.tangem.datasource.local.datastore.RuntimeSharedStore
|
||||
import com.tangem.datasource.local.visa.entity.PaymentAccountStatusDM
|
||||
import com.tangem.datasource.utils.MoshiDataStoreSerializer
|
||||
import com.tangem.datasource.utils.mapWithStringKeyTypes
|
||||
import com.tangem.domain.pay.TangemPayCryptoCurrencyFactory
|
||||
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.ProduceTangemPayInitialDataUseCase
|
||||
import com.tangem.domain.pay.usecase.TangemPayMainScreenCustomerInfoUseCase
|
||||
|
|
@ -16,11 +31,15 @@ import com.tangem.domain.tangempay.GetTangemPayCustomerIdUseCase
|
|||
import com.tangem.domain.tangempay.TangemPayWithdrawUseCase
|
||||
import com.tangem.domain.tangempay.repository.TangemPayTxHistoryRepository
|
||||
import com.tangem.security.DeviceSecurityInfoProvider
|
||||
import com.tangem.utils.coroutines.CoroutineDispatcherProvider
|
||||
import dagger.Binds
|
||||
import dagger.Module
|
||||
import dagger.Provides
|
||||
import dagger.hilt.InstallIn
|
||||
import dagger.hilt.android.qualifiers.ApplicationContext
|
||||
import dagger.hilt.components.SingletonComponent
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.SupervisorJob
|
||||
import javax.inject.Singleton
|
||||
|
||||
@Module
|
||||
|
|
@ -75,7 +94,51 @@ internal interface TangemPayDataModule {
|
|||
@Singleton
|
||||
fun bindTangemPayEligibilityManager(impl: DefaultTangemPayEligibilityManager): TangemPayEligibilityManager
|
||||
|
||||
@Binds
|
||||
@Singleton
|
||||
fun bindPaymentAccountStatusProducerFactory(
|
||||
impl: DefaultPaymentAccountStatusProducer.Factory,
|
||||
): PaymentAccountStatusProducer.Factory
|
||||
|
||||
@Binds
|
||||
@Singleton
|
||||
fun bindPaymentAccountStatusFetcher(impl: DefaultPaymentAccountStatusFetcher): PaymentAccountStatusFetcher
|
||||
|
||||
companion object {
|
||||
|
||||
@Provides
|
||||
@Singleton
|
||||
fun providePaymentAccountStatusesStore(
|
||||
@NetworkMoshi moshi: Moshi,
|
||||
@ApplicationContext context: Context,
|
||||
dispatchers: CoroutineDispatcherProvider,
|
||||
): PaymentAccountStatusesStore {
|
||||
return PaymentAccountStatusesStore(
|
||||
runtimeStore = RuntimeSharedStore(),
|
||||
persistenceDataStore = DataStoreFactory.create(
|
||||
serializer = MoshiDataStoreSerializer(
|
||||
moshi = moshi,
|
||||
types = mapWithStringKeyTypes<PaymentAccountStatusDM>(),
|
||||
defaultValue = emptyMap(),
|
||||
),
|
||||
produceFile = { context.dataStoreFile(fileName = "payment_account_statuses") },
|
||||
scope = CoroutineScope(context = dispatchers.io + SupervisorJob()),
|
||||
),
|
||||
dispatchers = dispatchers,
|
||||
)
|
||||
}
|
||||
|
||||
@Provides
|
||||
@Singleton
|
||||
fun providePaymentAccountStatusSupplier(
|
||||
factory: PaymentAccountStatusProducer.Factory,
|
||||
): PaymentAccountStatusSupplier {
|
||||
return object : PaymentAccountStatusSupplier(
|
||||
factory = factory,
|
||||
keyCreator = { "payment_account_status_${it.userWalletId.stringValue}" },
|
||||
) {}
|
||||
}
|
||||
|
||||
@Provides
|
||||
@Singleton
|
||||
fun provideTangemPayMainScreenCustomerInfoUseCase(
|
||||
|
|
|
|||
|
|
@ -0,0 +1,178 @@
|
|||
package com.tangem.data.pay.flow
|
||||
|
||||
import arrow.core.Either
|
||||
import com.tangem.data.pay.store.PaymentAccountStatusesStore
|
||||
import com.tangem.domain.core.utils.eitherOn
|
||||
import com.tangem.domain.models.StatusSource
|
||||
import com.tangem.domain.models.kyc.KycStatus
|
||||
import com.tangem.domain.models.wallet.UserWalletId
|
||||
import com.tangem.domain.pay.PaymentAccountStatus
|
||||
import com.tangem.domain.pay.flow.PaymentAccountStatusFetcher
|
||||
import com.tangem.domain.pay.model.CustomerInfo
|
||||
import com.tangem.domain.pay.model.OrderStatus
|
||||
import com.tangem.domain.pay.repository.CustomerOrderRepository
|
||||
import com.tangem.domain.pay.repository.OnboardingRepository
|
||||
import com.tangem.domain.visa.error.VisaApiError
|
||||
import com.tangem.security.DeviceSecurityInfoProvider
|
||||
import com.tangem.security.isSecurityExposed
|
||||
import com.tangem.utils.coroutines.CoroutineDispatcherProvider
|
||||
import timber.log.Timber
|
||||
import javax.inject.Inject
|
||||
|
||||
private const val TAG = "PaymentAccountStatusFetcher"
|
||||
|
||||
internal class DefaultPaymentAccountStatusFetcher @Inject constructor(
|
||||
private val paymentAccountStatusesStore: PaymentAccountStatusesStore,
|
||||
private val onboardingRepository: OnboardingRepository,
|
||||
private val customerOrderRepository: CustomerOrderRepository,
|
||||
private val deviceSecurity: DeviceSecurityInfoProvider,
|
||||
private val dispatchers: CoroutineDispatcherProvider,
|
||||
) : PaymentAccountStatusFetcher {
|
||||
|
||||
override suspend fun invoke(params: PaymentAccountStatusFetcher.Params): Either<Throwable, Unit> =
|
||||
eitherOn(dispatchers.default) {
|
||||
Timber.tag(TAG).i("fetch: ${params.userWalletId.stringValue}")
|
||||
|
||||
if (deviceSecurity.isSecurityExposed()) {
|
||||
Timber.tag(TAG).i("fetch security info: rooted: ${deviceSecurity.isRooted}")
|
||||
Timber.tag(TAG).i("fetch security info: xposed: ${deviceSecurity.isXposed}")
|
||||
Timber.tag(TAG).i("fetch security info: bootloader unlocked: ${deviceSecurity.isBootloaderUnlocked}")
|
||||
|
||||
return@eitherOn paymentAccountStatusesStore.store(
|
||||
userWalletId = params.userWalletId,
|
||||
status = PaymentAccountStatus.Error.ExposedDevice,
|
||||
)
|
||||
}
|
||||
|
||||
val status = onboardingRepository.hasTangemPayInWallet(userWalletId = params.userWalletId)
|
||||
.fold(
|
||||
ifLeft = { error ->
|
||||
Timber.tag(TAG).e("Failed check wallet ${params.userWalletId}: ${error.javaClass.simpleName}")
|
||||
when (error) {
|
||||
is VisaApiError.NotPaeraCustomer -> PaymentAccountStatus.NotCreated
|
||||
else -> PaymentAccountStatus.Error.Unavailable(source = StatusSource.ACTUAL)
|
||||
}
|
||||
},
|
||||
ifRight = { hasTangemPay ->
|
||||
proceedHasTangemPayResult(userWalletId = params.userWalletId, hasTangemPay = hasTangemPay)
|
||||
},
|
||||
)
|
||||
Timber.tag(TAG).i("invoke status ${params.userWalletId}: $status")
|
||||
paymentAccountStatusesStore.store(userWalletId = params.userWalletId, status = status)
|
||||
}
|
||||
|
||||
private suspend fun proceedHasTangemPayResult(
|
||||
userWalletId: UserWalletId,
|
||||
hasTangemPay: Boolean,
|
||||
): PaymentAccountStatus {
|
||||
Timber.tag(TAG).i("proceedHasTangemPayResult for $userWalletId hasTangemPay: $hasTangemPay")
|
||||
return if (hasTangemPay) {
|
||||
fetchTangemPayAccountStatus(userWalletId = userWalletId)
|
||||
} else {
|
||||
PaymentAccountStatus.NotCreated
|
||||
}
|
||||
}
|
||||
|
||||
private suspend fun fetchTangemPayAccountStatus(userWalletId: UserWalletId): PaymentAccountStatus {
|
||||
val prevResult = paymentAccountStatusesStore.getSyncOrNull(userWalletId)
|
||||
if (prevResult == null || prevResult is PaymentAccountStatus.Error) {
|
||||
paymentAccountStatusesStore.store(userWalletId = userWalletId, status = PaymentAccountStatus.Loading)
|
||||
}
|
||||
|
||||
return proceedWithOrderId(userWalletId = userWalletId)
|
||||
}
|
||||
|
||||
private suspend fun proceedWithOrderId(userWalletId: UserWalletId): PaymentAccountStatus {
|
||||
return if (!onboardingRepository.isTangemPayInitialDataProduced(userWalletId)) {
|
||||
PaymentAccountStatus.Error.NotSynced
|
||||
} else {
|
||||
val orderId = onboardingRepository.getOrderId(userWalletId)
|
||||
if (orderId != null) {
|
||||
proceedWithOrderId(userWalletId = userWalletId, orderId = orderId)
|
||||
} else {
|
||||
proceedWithoutOrder(userWalletId = userWalletId)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private suspend fun proceedWithoutOrder(userWalletId: UserWalletId): PaymentAccountStatus {
|
||||
return onboardingRepository.getCustomerInfo(userWalletId).fold(
|
||||
ifLeft = { error ->
|
||||
Timber.tag(TAG).e("proceedWithoutOrder $userWalletId error: $error")
|
||||
error.mapToPaymentAccountStatus()
|
||||
},
|
||||
ifRight = { customerInfo ->
|
||||
Timber.tag(TAG).i("proceedWithoutOrder data customerInfo $userWalletId")
|
||||
val status = customerInfo.mapToPaymentAccountStatus()
|
||||
if (status is PaymentAccountStatus.IssuingCard && customerInfo.kycStatus == KycStatus.APPROVED) {
|
||||
// If order id wasn't saved -> start order creation and get customer info
|
||||
onboardingRepository.createOrder(userWalletId)
|
||||
}
|
||||
status
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
private suspend fun proceedWithOrderId(userWalletId: UserWalletId, orderId: String): PaymentAccountStatus {
|
||||
return customerOrderRepository.getOrderData(userWalletId, orderId = orderId).fold(
|
||||
ifLeft = { error ->
|
||||
Timber.tag(TAG).e("proceedWithOrderId $userWalletId orderId: $orderId error: $error")
|
||||
error.mapToPaymentAccountStatus()
|
||||
},
|
||||
ifRight = { orderData ->
|
||||
Timber.tag(TAG).i("proceedWithOrderId $userWalletId: $orderId status: ${orderData.status}")
|
||||
when (orderData.status) {
|
||||
// Kyc is passed and user waits for order creation -> no need to get customer info
|
||||
OrderStatus.NEW,
|
||||
OrderStatus.PROCESSING,
|
||||
-> PaymentAccountStatus.IssuingCard(source = StatusSource.ACTUAL)
|
||||
|
||||
OrderStatus.CANCELED -> {
|
||||
// If order was cancelled -> clear previous order from local storage and start order creation
|
||||
onboardingRepository.clearOrderId(userWalletId)
|
||||
onboardingRepository.createOrder(userWalletId)
|
||||
PaymentAccountStatus.Error.CardIssueFailed
|
||||
}
|
||||
OrderStatus.COMPLETED -> {
|
||||
// Order was completed -> clear order id and get customer info
|
||||
onboardingRepository.clearOrderId(userWalletId)
|
||||
onboardingRepository.getCustomerInfo(userWalletId = userWalletId)
|
||||
.fold(
|
||||
ifLeft = { it.mapToPaymentAccountStatus() },
|
||||
ifRight = { customerInfo -> customerInfo.mapToPaymentAccountStatus() },
|
||||
)
|
||||
}
|
||||
OrderStatus.UNKNOWN -> PaymentAccountStatus.Error.Unavailable(source = StatusSource.ACTUAL)
|
||||
}
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
private fun CustomerInfo.mapToPaymentAccountStatus(): PaymentAccountStatus {
|
||||
val cardInfo = this.cardInfo
|
||||
val productInstance = this.productInstance
|
||||
return if (kycStatus != KycStatus.APPROVED && !customerId.isNullOrEmpty()) {
|
||||
PaymentAccountStatus.UnderReview(source = StatusSource.ACTUAL, kycStatus = kycStatus)
|
||||
} else if (cardInfo != null && productInstance != null) {
|
||||
PaymentAccountStatus.Loaded(
|
||||
source = StatusSource.ACTUAL,
|
||||
cardId = productInstance.cardId,
|
||||
lastFourDigits = cardInfo.lastFourDigits,
|
||||
balance = cardInfo.balance,
|
||||
currencyCode = cardInfo.currencyCode,
|
||||
depositAddress = cardInfo.depositAddress,
|
||||
isPinSet = cardInfo.isPinSet,
|
||||
)
|
||||
} else {
|
||||
PaymentAccountStatus.IssuingCard(source = StatusSource.ACTUAL)
|
||||
}
|
||||
}
|
||||
|
||||
private fun VisaApiError.mapToPaymentAccountStatus(): PaymentAccountStatus {
|
||||
return when (this) {
|
||||
is VisaApiError.RefreshTokenExpired -> PaymentAccountStatus.Error.NotSynced
|
||||
is VisaApiError.NotPaeraCustomer -> PaymentAccountStatus.NotCreated
|
||||
else -> PaymentAccountStatus.Error.Unavailable(source = StatusSource.ACTUAL)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,37 @@
|
|||
package com.tangem.data.pay.flow
|
||||
|
||||
import arrow.core.Option
|
||||
import arrow.core.some
|
||||
import com.tangem.data.pay.store.PaymentAccountStatusesStore
|
||||
import com.tangem.domain.core.flow.FlowProducerTools
|
||||
import com.tangem.domain.models.StatusSource
|
||||
import com.tangem.domain.pay.PaymentAccountStatus
|
||||
import com.tangem.domain.pay.flow.PaymentAccountStatusProducer
|
||||
import com.tangem.utils.coroutines.CoroutineDispatcherProvider
|
||||
import dagger.assisted.Assisted
|
||||
import dagger.assisted.AssistedFactory
|
||||
import dagger.assisted.AssistedInject
|
||||
import kotlinx.coroutines.flow.Flow
|
||||
import kotlinx.coroutines.flow.flowOn
|
||||
import kotlinx.coroutines.flow.onEmpty
|
||||
|
||||
internal class DefaultPaymentAccountStatusProducer @AssistedInject constructor(
|
||||
@Assisted private val params: PaymentAccountStatusProducer.Params,
|
||||
override val flowProducerTools: FlowProducerTools,
|
||||
private val paymentAccountStatusesStore: PaymentAccountStatusesStore,
|
||||
private val dispatchers: CoroutineDispatcherProvider,
|
||||
) : PaymentAccountStatusProducer {
|
||||
override val fallback: Option<PaymentAccountStatus>
|
||||
get() = PaymentAccountStatus.Error.Unavailable(source = StatusSource.ACTUAL).some()
|
||||
|
||||
override fun produce(): Flow<PaymentAccountStatus> {
|
||||
return paymentAccountStatusesStore.get(userWalletId = params.userWalletId)
|
||||
.onEmpty { emit(value = PaymentAccountStatus.NotCreated) }
|
||||
.flowOn(dispatchers.default)
|
||||
}
|
||||
|
||||
@AssistedFactory
|
||||
interface Factory : PaymentAccountStatusProducer.Factory {
|
||||
override fun create(params: PaymentAccountStatusProducer.Params): DefaultPaymentAccountStatusProducer
|
||||
}
|
||||
}
|
||||
|
|
@ -2,6 +2,7 @@ package com.tangem.data.pay.repository
|
|||
|
||||
import arrow.core.Either
|
||||
import com.tangem.datasource.api.pay.TangemPayApi
|
||||
import com.tangem.datasource.api.pay.models.response.OrderResponse
|
||||
import com.tangem.domain.models.wallet.UserWalletId
|
||||
import com.tangem.domain.pay.model.OrderData
|
||||
import com.tangem.domain.pay.model.OrderStatus
|
||||
|
|
@ -19,11 +20,11 @@ internal class DefaultCustomerOrderRepository @Inject constructor(
|
|||
tangemPayApi.getOrder(authHeader = authHeader, orderId = orderId)
|
||||
}.map { response ->
|
||||
val status = when (response.result?.status) {
|
||||
null -> OrderStatus.UNKNOWN
|
||||
OrderStatus.NEW.apiName -> OrderStatus.NEW
|
||||
OrderStatus.PROCESSING.apiName -> OrderStatus.PROCESSING
|
||||
OrderStatus.COMPLETED.apiName -> OrderStatus.COMPLETED
|
||||
else -> OrderStatus.CANCELED
|
||||
null -> OrderStatus.PROCESSING
|
||||
OrderResponse.Result.Status.NEW -> OrderStatus.NEW
|
||||
OrderResponse.Result.Status.PROCESSING -> OrderStatus.PROCESSING
|
||||
OrderResponse.Result.Status.COMPLETED -> OrderStatus.COMPLETED
|
||||
OrderResponse.Result.Status.CANCELED -> OrderStatus.CANCELED
|
||||
}
|
||||
OrderData(
|
||||
status = status,
|
||||
|
|
|
|||
|
|
@ -10,6 +10,7 @@ import com.tangem.datasource.api.pay.models.response.CustomerMeResponse
|
|||
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.kyc.KycStatus
|
||||
import com.tangem.domain.models.wallet.UserWallet
|
||||
import com.tangem.domain.models.wallet.UserWalletId
|
||||
import com.tangem.domain.pay.datasource.TangemPayAuthDataSource
|
||||
|
|
@ -27,9 +28,6 @@ import java.util.concurrent.ConcurrentHashMap
|
|||
import javax.inject.Inject
|
||||
|
||||
private const val VALID_STATUS = "valid"
|
||||
private const val APPROVED_KYC_STATUS = "approved"
|
||||
private const val IN_PROGRESS_KYC_STATUS = "in_progress"
|
||||
private const val DECLINED_KYC_STATUS = "declined"
|
||||
private const val TAG = "TangemPay: OnboardingRepository"
|
||||
|
||||
@Suppress("LongParameterList")
|
||||
|
|
@ -145,7 +143,6 @@ internal class DefaultOnboardingRepository @Inject constructor(
|
|||
lastFourDigits = card.cardNumberEnd,
|
||||
balance = fiatBalance.availableBalance,
|
||||
currencyCode = fiatBalance.currency,
|
||||
customerWalletAddress = paymentAccount.customerWalletAddress,
|
||||
depositAddress = response.depositAddress,
|
||||
isPinSet = response.card?.isPinSet == true,
|
||||
)
|
||||
|
|
@ -159,19 +156,19 @@ internal class DefaultOnboardingRepository @Inject constructor(
|
|||
}
|
||||
cardFrozenStateStore.store(key = instance.cardId, value = cardFrozenState)
|
||||
|
||||
ProductInstance(id = instance.id, cardId = instance.cardId, cardFrozenState = cardFrozenState)
|
||||
ProductInstance(id = instance.id, cardId = instance.cardId)
|
||||
}
|
||||
return CustomerInfo(
|
||||
customerId = response?.id,
|
||||
productInstance = productInstance,
|
||||
kycStatus = getKycStatus(status = response?.kyc?.status),
|
||||
kycStatus = KycStatus.fromString(status = response?.kyc?.status),
|
||||
cardInfo = cardInfo,
|
||||
).also {
|
||||
lastFetchedCustomerInfoMap[userWalletId] = it
|
||||
}
|
||||
}
|
||||
|
||||
override suspend fun checkCustomerWallet(userWalletId: UserWalletId): Either<VisaApiError, Boolean> {
|
||||
override suspend fun hasTangemPayInWallet(userWalletId: UserWalletId): Either<VisaApiError, Boolean> {
|
||||
val hasTangemPay = tangemPayStorage.checkCustomerWalletResult(userWalletId)
|
||||
if (hasTangemPay != null) {
|
||||
return Either.Right(hasTangemPay)
|
||||
|
|
@ -228,13 +225,4 @@ internal class DefaultOnboardingRepository @Inject constructor(
|
|||
setHideMainOnboardingBanner(userWalletId)
|
||||
}
|
||||
}
|
||||
|
||||
private fun getKycStatus(status: String?): CustomerInfo.KycStatus {
|
||||
return when (status?.lowercase()) {
|
||||
IN_PROGRESS_KYC_STATUS -> CustomerInfo.KycStatus.PENDING
|
||||
DECLINED_KYC_STATUS -> CustomerInfo.KycStatus.REJECTED
|
||||
APPROVED_KYC_STATUS -> CustomerInfo.KycStatus.APPROVED
|
||||
else -> CustomerInfo.KycStatus.INIT
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -16,10 +16,10 @@ import com.tangem.datasource.api.pay.models.request.CardDetailsRequest
|
|||
import com.tangem.datasource.api.pay.models.request.FreezeUnfreezeCardRequest
|
||||
import com.tangem.datasource.api.pay.models.request.SetPinRequest
|
||||
import com.tangem.datasource.api.pay.models.response.FreezeUnfreezeCardResponse
|
||||
import com.tangem.datasource.api.pay.models.response.OrderResponse.Result.Status
|
||||
import com.tangem.datasource.local.visa.TangemPayCardFrozenStateStore
|
||||
import com.tangem.datasource.local.visa.TangemPayStorage
|
||||
import com.tangem.domain.models.wallet.UserWalletId
|
||||
import com.tangem.domain.pay.model.OrderStatus
|
||||
import com.tangem.domain.pay.model.SetPinResult
|
||||
import com.tangem.domain.pay.model.TangemPayCardBalance
|
||||
import com.tangem.domain.pay.model.TangemPayCardDetails
|
||||
|
|
@ -279,15 +279,15 @@ internal class DefaultTangemPayCardDetailsRepository @Inject constructor(
|
|||
|
||||
orderStatus.onRight { response ->
|
||||
val status = response.result?.status
|
||||
if (status == OrderStatus.COMPLETED.apiName || status == OrderStatus.CANCELED.apiName) {
|
||||
if (status == Status.COMPLETED || status == Status.CANCELED) {
|
||||
// Remove from jobs
|
||||
pollingJobs.remove(key = orderId)
|
||||
|
||||
// Final card state
|
||||
val finalState = when {
|
||||
status == OrderStatus.COMPLETED.apiName && isFreeze
|
||||
status == Status.COMPLETED && isFreeze
|
||||
-> TangemPayCardFrozenState.Frozen
|
||||
status == OrderStatus.COMPLETED.apiName && !isFreeze
|
||||
status == Status.COMPLETED && !isFreeze
|
||||
-> TangemPayCardFrozenState.Unfrozen
|
||||
else -> return@launch
|
||||
}
|
||||
|
|
|
|||
|
|
@ -77,34 +77,22 @@ internal class DefaultTangemPayTxHistoryRepository @Inject constructor(
|
|||
limit: Int,
|
||||
): List<TangemPayTxHistoryItem> {
|
||||
cacheRegistry.invokeOnExpire(
|
||||
key = getCacheKey(customerWalletAddress = config.customerWalletAddress, cursor = cursor),
|
||||
key = getCacheKey(userWalletId = userWalletId, cursor = cursor),
|
||||
skipCache = config.shouldRefresh,
|
||||
block = {
|
||||
fetch(
|
||||
userWalletId = userWalletId,
|
||||
customerWalletAddress = config.customerWalletAddress,
|
||||
cursor = cursor,
|
||||
pageSize = limit,
|
||||
)
|
||||
},
|
||||
block = { fetch(userWalletId = userWalletId, cursor = cursor, pageSize = limit) },
|
||||
)
|
||||
|
||||
return txHistoryItemsStore.getSyncOrNull(
|
||||
key = config.customerWalletAddress,
|
||||
key = userWalletId.stringValue,
|
||||
cursor = cursor ?: INITIAL_CURSOR,
|
||||
).orEmpty()
|
||||
}
|
||||
|
||||
private fun getCacheKey(customerWalletAddress: String, cursor: String?): String {
|
||||
return "tangem_pay_tx_history_${customerWalletAddress}_${cursor ?: INITIAL_CURSOR}"
|
||||
private fun getCacheKey(userWalletId: UserWalletId, cursor: String?): String {
|
||||
return "tangem_pay_tx_history_${userWalletId.stringValue}_${cursor ?: INITIAL_CURSOR}"
|
||||
}
|
||||
|
||||
private suspend fun fetch(
|
||||
userWalletId: UserWalletId,
|
||||
customerWalletAddress: String,
|
||||
cursor: String?,
|
||||
pageSize: Int,
|
||||
) {
|
||||
private suspend fun fetch(userWalletId: UserWalletId, cursor: String?, pageSize: Int) {
|
||||
requestPerformer.performRequest(userWalletId = userWalletId) { authHeader ->
|
||||
visaApi.getTangemPayTxHistory(authHeader = authHeader, limit = pageSize, cursor = cursor)
|
||||
}.onLeft {
|
||||
|
|
@ -112,7 +100,7 @@ internal class DefaultTangemPayTxHistoryRepository @Inject constructor(
|
|||
}.onRight { response ->
|
||||
val result = response.result
|
||||
val items = txHistoryItemConverter.convertList(result.transactions).filterNotNull()
|
||||
txHistoryItemsStore.store(key = customerWalletAddress, cursor = cursor ?: INITIAL_CURSOR, value = items)
|
||||
txHistoryItemsStore.store(key = userWalletId.stringValue, cursor = cursor ?: INITIAL_CURSOR, value = items)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,86 @@
|
|||
package com.tangem.data.pay.store
|
||||
|
||||
import androidx.datastore.core.DataStore
|
||||
import com.tangem.data.pay.converter.PaymentAccountStatusDMConverter
|
||||
import com.tangem.datasource.local.datastore.RuntimeSharedStore
|
||||
import com.tangem.datasource.local.visa.entity.PaymentAccountStatusDM
|
||||
import com.tangem.domain.models.wallet.UserWalletId
|
||||
import com.tangem.domain.pay.PaymentAccountStatus
|
||||
import com.tangem.utils.coroutines.CoroutineDispatcherProvider
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.SupervisorJob
|
||||
import kotlinx.coroutines.coroutineScope
|
||||
import kotlinx.coroutines.flow.Flow
|
||||
import kotlinx.coroutines.flow.firstOrNull
|
||||
import kotlinx.coroutines.flow.mapNotNull
|
||||
import kotlinx.coroutines.launch
|
||||
import timber.log.Timber
|
||||
|
||||
internal typealias WalletIdWithPaymentStatus = Map<String, PaymentAccountStatus>
|
||||
internal typealias WalletIdWithPaymentStatusDM = Map<String, PaymentAccountStatusDM>
|
||||
|
||||
/**
|
||||
* Store for payment account statuses with dual storage (runtime + persistence).
|
||||
*
|
||||
* @property runtimeStore runtime store for fast in-memory access
|
||||
* @property persistenceDataStore persistence store for caching across app restarts
|
||||
*/
|
||||
internal class PaymentAccountStatusesStore(
|
||||
private val runtimeStore: RuntimeSharedStore<WalletIdWithPaymentStatus>,
|
||||
private val persistenceDataStore: DataStore<WalletIdWithPaymentStatusDM>,
|
||||
dispatchers: CoroutineDispatcherProvider,
|
||||
) {
|
||||
|
||||
private val scope = CoroutineScope(context = SupervisorJob() + dispatchers.io)
|
||||
|
||||
init {
|
||||
scope.launch {
|
||||
try {
|
||||
val cachedStatuses = persistenceDataStore.data.firstOrNull() ?: return@launch
|
||||
runtimeStore.store(
|
||||
value = cachedStatuses.mapValues { (_, statusDM) ->
|
||||
PaymentAccountStatusDMConverter.convertBack(statusDM)
|
||||
},
|
||||
)
|
||||
} catch (e: Exception) {
|
||||
Timber.e(e, "Error while loading cached payment account statuses")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun get(userWalletId: UserWalletId): Flow<PaymentAccountStatus> {
|
||||
return runtimeStore.get().mapNotNull { it[userWalletId.stringValue] }
|
||||
}
|
||||
|
||||
suspend fun getSyncOrNull(userWalletId: UserWalletId): PaymentAccountStatus? {
|
||||
return runtimeStore.getSyncOrNull()?.get(userWalletId.stringValue)
|
||||
}
|
||||
|
||||
suspend fun store(userWalletId: UserWalletId, status: PaymentAccountStatus) {
|
||||
coroutineScope {
|
||||
launch { storeInRuntime(userWalletId = userWalletId, status = status) }
|
||||
launch { storeInPersistence(userWalletId = userWalletId, status = status) }
|
||||
}
|
||||
}
|
||||
|
||||
suspend fun contains(userWalletId: UserWalletId): Boolean {
|
||||
return runtimeStore.getSyncOrDefault(emptyMap()).containsKey(userWalletId.stringValue)
|
||||
}
|
||||
|
||||
private suspend fun storeInRuntime(userWalletId: UserWalletId, status: PaymentAccountStatus) {
|
||||
runtimeStore.update(default = emptyMap()) { stored ->
|
||||
stored.toMutableMap().apply {
|
||||
put(key = userWalletId.stringValue, value = status)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private suspend fun storeInPersistence(userWalletId: UserWalletId, status: PaymentAccountStatus) {
|
||||
val statusDM = PaymentAccountStatusDMConverter.convert(value = status) ?: return
|
||||
persistenceDataStore.updateData { storedStatuses ->
|
||||
storedStatuses.toMutableMap().apply {
|
||||
put(key = userWalletId.stringValue, value = statusDM)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,101 @@
|
|||
package com.tangem.data.wallets.derivations
|
||||
|
||||
import com.tangem.common.card.EllipticCurve
|
||||
import com.tangem.domain.card.common.TapWorkarounds.hasOldStyleDerivation
|
||||
import com.tangem.domain.models.scan.KeyWalletPublicKey
|
||||
import com.tangem.domain.models.scan.ScanResponse
|
||||
import com.tangem.domain.models.wallet.UserWallet
|
||||
import com.tangem.domain.wallets.config.ColdCurvesConfig
|
||||
import com.tangem.domain.wallets.config.CurvesConfig
|
||||
import com.tangem.domain.wallets.config.curvesConfig
|
||||
import com.tangem.domain.wallets.derivations.DerivationStyleProvider
|
||||
import com.tangem.domain.wallets.derivations.derivationStyleProvider
|
||||
import com.tangem.operations.derivation.ExtendedPublicKeysMap
|
||||
|
||||
/**
|
||||
* Source of derivations data
|
||||
*/
|
||||
internal sealed interface DerivationsSource {
|
||||
|
||||
val isHDWalletAllowed: Boolean
|
||||
val hasOldStyleDerivation: Boolean
|
||||
val curvesConfig: CurvesConfig
|
||||
val derivationStyleProvider: DerivationStyleProvider
|
||||
|
||||
fun getWalletPublicKey(curve: EllipticCurve): ByteArray?
|
||||
fun getDerivedKeys(publicKey: KeyWalletPublicKey): ExtendedPublicKeysMap
|
||||
|
||||
data class FromUserWallet(val userWallet: UserWallet) : DerivationsSource {
|
||||
override val isHDWalletAllowed: Boolean
|
||||
get() = when (userWallet) {
|
||||
is UserWallet.Cold -> userWallet.scanResponse.card.settings.isHDWalletAllowed
|
||||
is UserWallet.Hot -> true
|
||||
}
|
||||
|
||||
override val hasOldStyleDerivation: Boolean
|
||||
get() = when (userWallet) {
|
||||
is UserWallet.Cold -> userWallet.scanResponse.card.hasOldStyleDerivation
|
||||
is UserWallet.Hot -> false
|
||||
}
|
||||
|
||||
override val curvesConfig: CurvesConfig
|
||||
get() = userWallet.curvesConfig
|
||||
|
||||
override val derivationStyleProvider: DerivationStyleProvider
|
||||
get() = userWallet.derivationStyleProvider
|
||||
|
||||
override fun getWalletPublicKey(curve: EllipticCurve): ByteArray? {
|
||||
return when (userWallet) {
|
||||
is UserWallet.Cold -> userWallet.scanResponse.getWalletPublicKey(curve)
|
||||
is UserWallet.Hot -> userWallet.wallets
|
||||
?.firstOrNull { it.curve == curve && it.chainCode != null }
|
||||
?.publicKey
|
||||
}
|
||||
}
|
||||
|
||||
override fun getDerivedKeys(publicKey: KeyWalletPublicKey): ExtendedPublicKeysMap {
|
||||
return when (userWallet) {
|
||||
is UserWallet.Cold -> userWallet.scanResponse.getDerivedKeys(publicKey)
|
||||
is UserWallet.Hot -> {
|
||||
val derivedKeys = userWallet.wallets
|
||||
?.firstOrNull { it.publicKey.contentEquals(publicKey.bytes) }
|
||||
?.derivedKeys
|
||||
.orEmpty()
|
||||
|
||||
ExtendedPublicKeysMap(derivedKeys)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
data class FromScanResponse(val scanResponse: ScanResponse) : DerivationsSource {
|
||||
override val isHDWalletAllowed: Boolean
|
||||
get() = scanResponse.card.settings.isHDWalletAllowed
|
||||
|
||||
override val hasOldStyleDerivation: Boolean
|
||||
get() = scanResponse.card.hasOldStyleDerivation
|
||||
|
||||
override val curvesConfig: CurvesConfig
|
||||
get() = ColdCurvesConfig(scanResponse.card)
|
||||
|
||||
override val derivationStyleProvider: DerivationStyleProvider
|
||||
get() = scanResponse.derivationStyleProvider
|
||||
|
||||
override fun getWalletPublicKey(curve: EllipticCurve): ByteArray? {
|
||||
return scanResponse.getWalletPublicKey(curve)
|
||||
}
|
||||
|
||||
override fun getDerivedKeys(publicKey: KeyWalletPublicKey): ExtendedPublicKeysMap {
|
||||
return scanResponse.getDerivedKeys(publicKey)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun ScanResponse.getWalletPublicKey(curve: EllipticCurve): ByteArray? {
|
||||
return card.wallets.firstOrNull { it.curve == curve && it.chainCode != null }
|
||||
?.publicKey
|
||||
}
|
||||
|
||||
private fun ScanResponse.getDerivedKeys(publicKey: KeyWalletPublicKey): ExtendedPublicKeysMap {
|
||||
return derivedKeys[publicKey] ?: ExtendedPublicKeysMap(emptyMap())
|
||||
}
|
||||
|
|
@ -3,45 +3,80 @@ package com.tangem.data.wallets.derivations
|
|||
import com.tangem.blockchain.blockchains.cardano.CardanoUtils
|
||||
import com.tangem.blockchain.common.Blockchain
|
||||
import com.tangem.blockchainsdk.utils.toBlockchain
|
||||
import com.tangem.common.card.EllipticCurve
|
||||
import com.tangem.common.extensions.ByteArrayKey
|
||||
import com.tangem.common.extensions.toMapKey
|
||||
import com.tangem.crypto.hdWallet.DerivationPath
|
||||
import com.tangem.domain.models.currency.CryptoCurrency
|
||||
import com.tangem.domain.models.network.Network
|
||||
import com.tangem.domain.models.scan.KeyWalletPublicKey
|
||||
import com.tangem.domain.models.scan.ScanResponse
|
||||
import com.tangem.domain.models.wallet.UserWallet
|
||||
import com.tangem.domain.wallets.config.curvesConfig
|
||||
import com.tangem.domain.wallets.derivations.derivationStyleProvider
|
||||
import com.tangem.operations.derivation.ExtendedPublicKeysMap
|
||||
import kotlin.collections.forEach
|
||||
|
||||
private typealias DerivationData = Pair<ByteArrayKey, List<DerivationPath>>
|
||||
internal typealias Derivations = Map<ByteArrayKey, List<DerivationPath>>
|
||||
|
||||
/**
|
||||
* Data class representing a blockchain with its derivation path
|
||||
*/
|
||||
data class BlockchainToDerive(
|
||||
val blockchain: Blockchain,
|
||||
val derivationPath: DerivationPath,
|
||||
)
|
||||
|
||||
/**
|
||||
* Finder of missed derivations
|
||||
*
|
||||
* @property userWallet User wallet to find derivations for
|
||||
* @property source Source of derivations data (UserWallet or ScanResponse)
|
||||
*
|
||||
[REDACTED_AUTHOR]
|
||||
*/
|
||||
internal class MissedDerivationsFinder(private val userWallet: UserWallet) {
|
||||
class MissedDerivationsFinder private constructor(private val source: DerivationsSource) {
|
||||
|
||||
/**
|
||||
* Secondary constructor for backward compatibility with UserWallet
|
||||
*/
|
||||
constructor(userWallet: UserWallet) : this(DerivationsSource.FromUserWallet(userWallet))
|
||||
|
||||
/**
|
||||
* Secondary constructor for ScanResponse
|
||||
*/
|
||||
constructor(scanResponse: ScanResponse) : this(DerivationsSource.FromScanResponse(scanResponse))
|
||||
|
||||
/** Find missed derivations for given currencies [currencies] */
|
||||
fun find(currencies: List<CryptoCurrency>): Derivations {
|
||||
return currencies.map { it.network }.let(::findByNetworks)
|
||||
}
|
||||
|
||||
/** Find missed derivations for given [Network] list */
|
||||
fun findByNetworks(networks: List<Network>): Derivations {
|
||||
val blockchainsToDerive = networks.mapNotNull { network ->
|
||||
val blockchain = network.toBlockchain()
|
||||
val derivationPath = network.derivationPath.value?.let(::DerivationPath)
|
||||
?: return@mapNotNull null
|
||||
|
||||
BlockchainToDerive(blockchain, derivationPath)
|
||||
}
|
||||
return findByBlockchainsToDerive(blockchainsToDerive)
|
||||
}
|
||||
|
||||
/** Find missed derivations for given [BlockchainToDerive] list */
|
||||
fun findByBlockchainsToDerive(blockchainsToDerive: Collection<BlockchainToDerive>): Derivations {
|
||||
val enrichedBlockchains = blockchainsToDerive.enrichBlockchains()
|
||||
return findDerivationsInternal(enrichedBlockchains)
|
||||
}
|
||||
|
||||
/**
|
||||
* Common implementation for finding derivations
|
||||
*/
|
||||
private fun findDerivationsInternal(items: Collection<BlockchainToDerive>): Derivations {
|
||||
return buildMap<ByteArrayKey, MutableList<DerivationPath>> {
|
||||
networks
|
||||
.mapToNewDerivations()
|
||||
items
|
||||
.mapNotNull(::mapToNewDerivation)
|
||||
.forEach { data ->
|
||||
val current = this[data.first]
|
||||
if (current != null) {
|
||||
current.addAll(data.second)
|
||||
current.distinct()
|
||||
this[data.first] = current.distinct().toMutableList()
|
||||
} else {
|
||||
this[data.first] = data.second.toMutableList()
|
||||
}
|
||||
|
|
@ -49,31 +84,17 @@ internal class MissedDerivationsFinder(private val userWallet: UserWallet) {
|
|||
}
|
||||
}
|
||||
|
||||
private fun List<Network>.mapToNewDerivations(): List<DerivationData> {
|
||||
return mapNotNull { network ->
|
||||
val blockchain = network.toBlockchain()
|
||||
val curve = userWallet.curvesConfig.primaryCurve(blockchain) ?: return@mapNotNull null
|
||||
/**
|
||||
* Maps a single BlockchainToDerive to derivation data (public key -> derivation paths)
|
||||
*/
|
||||
private fun mapToNewDerivation(input: BlockchainToDerive): DerivationData? {
|
||||
val curve = source.curvesConfig.primaryCurve(input.blockchain) ?: return null
|
||||
if (!input.blockchain.getSupportedCurves().contains(curve)) return null
|
||||
|
||||
val walletPublicKey = when (userWallet) {
|
||||
is UserWallet.Cold -> {
|
||||
val wallet = userWallet.scanResponse.card.wallets.firstOrNull { it.curve == curve }
|
||||
wallet?.publicKey
|
||||
}
|
||||
is UserWallet.Hot -> {
|
||||
val wallet = userWallet.wallets?.firstOrNull { it.curve == curve }
|
||||
wallet?.publicKey
|
||||
}
|
||||
}
|
||||
val publicKey = source.getWalletPublicKey(curve) ?: return null
|
||||
|
||||
walletPublicKey?.let {
|
||||
findNewDerivations(curve = curve, publicKey = it, network = network)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun findNewDerivations(curve: EllipticCurve, publicKey: ByteArray, network: Network): DerivationData? {
|
||||
val derivationCandidates = network
|
||||
.getDerivationCandidates(curve)
|
||||
val derivationCandidates = input.blockchain
|
||||
.getDerivationCandidates(input.derivationPath)
|
||||
.ifEmpty { return null }
|
||||
.filterAlreadyDerivedKeys(publicKey.toMapKey())
|
||||
.ifEmpty { return null }
|
||||
|
|
@ -81,59 +102,63 @@ internal class MissedDerivationsFinder(private val userWallet: UserWallet) {
|
|||
return publicKey.toMapKey() to derivationCandidates
|
||||
}
|
||||
|
||||
private fun Network.getDerivationCandidates(curve: EllipticCurve): List<DerivationPath> {
|
||||
val blockchain = this.toBlockchain()
|
||||
|
||||
/**
|
||||
* Gets all possible derivation paths for a blockchain
|
||||
*/
|
||||
private fun Blockchain.getDerivationCandidates(derivationPath: DerivationPath): List<DerivationPath> {
|
||||
return buildList {
|
||||
add(blockchain.getDerivationPath(curve = curve))
|
||||
add(blockchain.getCustomDerivationPath(curve = curve, network = this@getDerivationCandidates))
|
||||
add(blockchain.getCardanoDerivationPathIfNeeded(network = this@getDerivationCandidates))
|
||||
// Default derivation path for blockchain
|
||||
add(getDerivationPath())
|
||||
|
||||
// The specified derivation path (can be either default or custom)
|
||||
add(derivationPath)
|
||||
|
||||
// Extended Cardano derivation path if needed
|
||||
add(getCardanoExtendedDerivationPath(derivationPath))
|
||||
}
|
||||
.filterNotNull()
|
||||
.distinct()
|
||||
}
|
||||
|
||||
private fun Blockchain.getDerivationPath(curve: EllipticCurve): DerivationPath? {
|
||||
return if (getSupportedCurves().contains(curve)) {
|
||||
derivationPath(style = userWallet.derivationStyleProvider.getDerivationStyle())
|
||||
} else {
|
||||
null
|
||||
}
|
||||
private fun Blockchain.getDerivationPath(): DerivationPath? {
|
||||
return derivationPath(style = source.derivationStyleProvider.getDerivationStyle())
|
||||
}
|
||||
|
||||
private fun Blockchain.getCustomDerivationPath(curve: EllipticCurve, network: Network): DerivationPath? {
|
||||
return if (getSupportedCurves().contains(curve)) {
|
||||
network.derivationPath.value?.let(::DerivationPath)
|
||||
} else {
|
||||
null
|
||||
}
|
||||
}
|
||||
|
||||
private fun Blockchain.getCardanoDerivationPathIfNeeded(network: Network): DerivationPath? {
|
||||
return if (this == Blockchain.Cardano) {
|
||||
network.derivationPath.value?.let {
|
||||
CardanoUtils.extendedDerivationPath(derivationPath = DerivationPath(it))
|
||||
}
|
||||
} else {
|
||||
null
|
||||
}
|
||||
private fun Blockchain.getCardanoExtendedDerivationPath(customDerivationPath: DerivationPath): DerivationPath? {
|
||||
if (this != Blockchain.Cardano) return null
|
||||
return CardanoUtils.extendedDerivationPath(derivationPath = customDerivationPath)
|
||||
}
|
||||
|
||||
private fun List<DerivationPath>.filterAlreadyDerivedKeys(publicKey: KeyWalletPublicKey): List<DerivationPath> {
|
||||
val alreadyDerivedPaths = getAlreadyDerivedKeys(publicKey)
|
||||
val alreadyDerivedPaths = source.getDerivedKeys(publicKey).keys.toList()
|
||||
return filterNot(alreadyDerivedPaths::contains)
|
||||
}
|
||||
|
||||
private fun getAlreadyDerivedKeys(publicKey: KeyWalletPublicKey): List<DerivationPath> {
|
||||
val extendedPublicKeysMap = when (userWallet) {
|
||||
is UserWallet.Cold -> userWallet.scanResponse.derivedKeys[publicKey] ?: ExtendedPublicKeysMap(emptyMap())
|
||||
is UserWallet.Hot -> {
|
||||
val wallets = userWallet.wallets ?: return emptyList()
|
||||
wallets.firstOrNull { it.publicKey.contentEquals(publicKey.bytes) }?.derivedKeys
|
||||
?: ExtendedPublicKeysMap(emptyMap())
|
||||
}
|
||||
// region Blockchain enrichment logic
|
||||
|
||||
/**
|
||||
* Enriches blockchains collection:
|
||||
* - Adds Ethereum if HD wallet is allowed
|
||||
* - Removes unnecessary blockchains that share derivation path with Ethereum (for cards without old style derivation)
|
||||
*/
|
||||
private fun Collection<BlockchainToDerive>.enrichBlockchains(): Collection<BlockchainToDerive> {
|
||||
if (!source.isHDWalletAllowed) return this
|
||||
|
||||
val derivationStyle = source.derivationStyleProvider.getDerivationStyle()
|
||||
val ethereumDerivationPath = Blockchain.Ethereum.derivationPath(derivationStyle) ?: return this
|
||||
|
||||
val withEthereum = this + BlockchainToDerive(Blockchain.Ethereum, ethereumDerivationPath)
|
||||
|
||||
// For cards with old style derivation, keep all blockchains
|
||||
if (source.hasOldStyleDerivation) {
|
||||
return withEthereum.distinct()
|
||||
}
|
||||
|
||||
return extendedPublicKeysMap.keys.toList()
|
||||
// For new cards: filter out blockchains with same derivation path as Ethereum (except Ethereum itself)
|
||||
return withEthereum
|
||||
.filter { it.derivationPath != ethereumDerivationPath || it.blockchain == Blockchain.Ethereum }
|
||||
.distinct()
|
||||
}
|
||||
|
||||
// endregion
|
||||
}
|
||||
|
|
@ -47,18 +47,11 @@ class DefaultHotWalletAccessCodeAttemptsRepository @Inject constructor(
|
|||
hotWalletId = hotWalletId,
|
||||
auth = true,
|
||||
)
|
||||
val noAuthAttemptId = HotWalletAccessCodeAttemptsRepository.AttemptId(
|
||||
hotWalletId = hotWalletId,
|
||||
auth = false,
|
||||
)
|
||||
|
||||
appPreferencesStore.editData {
|
||||
it.remove(PreferencesKeys.getHotWalletUnlockAttemptsKey(authAttemptId.attemptIdKey()))
|
||||
it.remove(PreferencesKeys.getHotWalletUnlockAttemptsKey(noAuthAttemptId.attemptIdKey()))
|
||||
it.remove(PreferencesKeys.getHotWalletUnlockBootKey(authAttemptId.attemptIdKey()))
|
||||
it.remove(PreferencesKeys.getHotWalletUnlockBootKey(noAuthAttemptId.attemptIdKey()))
|
||||
it.remove(PreferencesKeys.getHotWalletUnlockDeadlineKey(authAttemptId.attemptIdKey()))
|
||||
it.remove(PreferencesKeys.getHotWalletUnlockDeadlineKey(noAuthAttemptId.attemptIdKey()))
|
||||
appPreferencesStore.editData { data ->
|
||||
data.remove(PreferencesKeys.getHotWalletUnlockAttemptsKey(authAttemptId.attemptIdKey()))
|
||||
data.remove(PreferencesKeys.getHotWalletUnlockBootKey(authAttemptId.attemptIdKey()))
|
||||
data.remove(PreferencesKeys.getHotWalletUnlockDeadlineKey(authAttemptId.attemptIdKey()))
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -119,13 +112,21 @@ class DefaultHotWalletAccessCodeAttemptsRepository @Inject constructor(
|
|||
}
|
||||
else -> {
|
||||
val remaining = remainingSeconds(deadlineElapsed, bootStored)
|
||||
Attempts.WithDelay(count, remaining)
|
||||
val newCount = if (id.auth) {
|
||||
count
|
||||
} else {
|
||||
MAX_FAST_FORWARD_ATTEMPTS
|
||||
}
|
||||
Attempts.WithDelay(newCount, remaining)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun HotWalletAccessCodeAttemptsRepository.AttemptId.attemptIdKey(): String {
|
||||
return "${hotWalletId.value}_$auth"
|
||||
// Regarding [REDACTED_TASK_KEY], the attempts counter must be shared between modes (auth vs signing).
|
||||
// To provide backward compatibility, we use the same keys but read attempts in auth mode for security reasons.
|
||||
val isAuthMode = true
|
||||
return "${hotWalletId.value}_$isAuthMode"
|
||||
}
|
||||
|
||||
private fun currentBootCount(): Int = Settings.Global.getInt(context.contentResolver, Settings.Global.BOOT_COUNT, 0)
|
||||
|
|
|
|||
|
|
@ -14,11 +14,13 @@ import com.tangem.domain.card.configs.GenericCardConfig
|
|||
import com.tangem.domain.card.configs.MultiWalletCardConfig
|
||||
import com.tangem.domain.card.configs.Wallet2CardConfig
|
||||
import com.tangem.domain.wallets.derivations.derivationStyleProvider
|
||||
import org.junit.Test
|
||||
import org.junit.jupiter.api.Test
|
||||
import org.junit.jupiter.api.TestInstance
|
||||
|
||||
/**
|
||||
[REDACTED_AUTHOR]
|
||||
*/
|
||||
@TestInstance(TestInstance.Lifecycle.PER_CLASS)
|
||||
internal class MissedDerivationsFinderTest {
|
||||
|
||||
@Test
|
||||
|
|
@ -97,9 +99,8 @@ internal class MissedDerivationsFinderTest {
|
|||
val currencies = MockCryptoCurrencyFactory(userWallet).cardano.let(::listOf)
|
||||
val actual = finder.find(currencies)
|
||||
|
||||
Truth.assertThat(actual).containsExactly(
|
||||
ByteArrayKey(EllipticCurve.Ed25519.name.toByteArray()),
|
||||
listOf(
|
||||
val expected = mapOf(
|
||||
ByteArrayKey(EllipticCurve.Ed25519.name.toByteArray()) to listOf(
|
||||
DerivationConfigV2.derivations(Blockchain.Cardano).values.first(),
|
||||
CardanoUtils.extendedDerivationPath(
|
||||
derivationPath = DerivationPath(
|
||||
|
|
@ -108,7 +109,12 @@ internal class MissedDerivationsFinderTest {
|
|||
),
|
||||
),
|
||||
),
|
||||
ByteArrayKey(EllipticCurve.Secp256k1.name.toByteArray()) to listOf(
|
||||
DerivationConfigV2.derivations(Blockchain.Ethereum).values.first(),
|
||||
),
|
||||
)
|
||||
|
||||
Truth.assertThat(actual).containsExactlyEntriesIn(expected)
|
||||
}
|
||||
|
||||
@Test
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue