Updated on 2026-08-14

This commit is contained in:
Tangem 2026-06-22 20:31:03 +05:00
parent ed26d23a78
commit f3192b6d27
23 changed files with 413 additions and 46 deletions

View file

@ -10,6 +10,7 @@ import com.tangem.sdk.api.TangemSdkManager
import com.tangem.tap.domain.sdk.impl.DefaultTangemSdkManager import com.tangem.tap.domain.sdk.impl.DefaultTangemSdkManager
import com.tangem.tap.domain.sdk.impl.MockTangemSdkManager import com.tangem.tap.domain.sdk.impl.MockTangemSdkManager
import com.tangem.tap.domain.tasks.visa.TangemPayGenerateAddressAndSignChallengeTask import com.tangem.tap.domain.tasks.visa.TangemPayGenerateAddressAndSignChallengeTask
import com.tangem.tap.domain.tasks.visa.TangemPayGenerateVirtualAccountAddressTask
import com.tangem.tap.domain.tasks.visa.VisaCardActivationTask import com.tangem.tap.domain.tasks.visa.VisaCardActivationTask
import com.tangem.tap.domain.visa.VisaCardScanHandler import com.tangem.tap.domain.visa.VisaCardScanHandler
import dagger.Module import dagger.Module
@ -31,6 +32,7 @@ internal class TangemSdkManagerModule {
visaCardScanHandler: VisaCardScanHandler, visaCardScanHandler: VisaCardScanHandler,
visaCardActivationTaskFactory: VisaCardActivationTask.Factory, visaCardActivationTaskFactory: VisaCardActivationTask.Factory,
tangemPayChallengeTaskFactory: TangemPayGenerateAddressAndSignChallengeTask.Factory, tangemPayChallengeTaskFactory: TangemPayGenerateAddressAndSignChallengeTask.Factory,
tangemPayVirtualAccountTaskFactory: TangemPayGenerateVirtualAccountAddressTask.Factory,
onboardingV2FeatureToggles: OnboardingV2FeatureToggles, onboardingV2FeatureToggles: OnboardingV2FeatureToggles,
analyticsErrorHandler: AnalyticsErrorHandler, analyticsErrorHandler: AnalyticsErrorHandler,
cardRepository: CardRepository, cardRepository: CardRepository,
@ -44,6 +46,7 @@ internal class TangemSdkManagerModule {
visaCardScanHandler = visaCardScanHandler, visaCardScanHandler = visaCardScanHandler,
visaCardActivationTaskFactory = visaCardActivationTaskFactory, visaCardActivationTaskFactory = visaCardActivationTaskFactory,
tangemPayChallengeTaskFactory = tangemPayChallengeTaskFactory, tangemPayChallengeTaskFactory = tangemPayChallengeTaskFactory,
tangemPayVirtualAccountTaskFactory = tangemPayVirtualAccountTaskFactory,
onboardingV2FeatureToggles = onboardingV2FeatureToggles, onboardingV2FeatureToggles = onboardingV2FeatureToggles,
analyticsErrorHandler = analyticsErrorHandler, analyticsErrorHandler = analyticsErrorHandler,
cardRepository = cardRepository, cardRepository = cardRepository,

View file

@ -50,6 +50,7 @@ import com.tangem.tap.common.analytics.events.TangemSdkErrorEvent
import com.tangem.tap.common.analytics.paramsInterceptor.CardContextInterceptor import com.tangem.tap.common.analytics.paramsInterceptor.CardContextInterceptor
import com.tangem.tap.domain.tasks.product.* import com.tangem.tap.domain.tasks.product.*
import com.tangem.tap.domain.tasks.visa.TangemPayGenerateAddressAndSignChallengeTask import com.tangem.tap.domain.tasks.visa.TangemPayGenerateAddressAndSignChallengeTask
import com.tangem.tap.domain.tasks.visa.TangemPayGenerateVirtualAccountAddressTask
import com.tangem.tap.domain.tasks.visa.TangemPaySignWithdrawalHashTask import com.tangem.tap.domain.tasks.visa.TangemPaySignWithdrawalHashTask
import com.tangem.tap.domain.tasks.visa.VisaCardActivationTask import com.tangem.tap.domain.tasks.visa.VisaCardActivationTask
import com.tangem.tap.domain.tasks.visa.VisaCustomerWalletApproveTask import com.tangem.tap.domain.tasks.visa.VisaCustomerWalletApproveTask
@ -72,6 +73,7 @@ internal class DefaultTangemSdkManager(
private val visaCardScanHandler: VisaCardScanHandler, private val visaCardScanHandler: VisaCardScanHandler,
private val visaCardActivationTaskFactory: VisaCardActivationTask.Factory, private val visaCardActivationTaskFactory: VisaCardActivationTask.Factory,
private val tangemPayChallengeTaskFactory: TangemPayGenerateAddressAndSignChallengeTask.Factory, private val tangemPayChallengeTaskFactory: TangemPayGenerateAddressAndSignChallengeTask.Factory,
private val tangemPayVirtualAccountTaskFactory: TangemPayGenerateVirtualAccountAddressTask.Factory,
private val onboardingV2FeatureToggles: OnboardingV2FeatureToggles, private val onboardingV2FeatureToggles: OnboardingV2FeatureToggles,
private val analyticsErrorHandler: AnalyticsErrorHandler, private val analyticsErrorHandler: AnalyticsErrorHandler,
private val cardRepository: CardRepository, private val cardRepository: CardRepository,
@ -531,6 +533,24 @@ internal class DefaultTangemSdkManager(
} }
} }
override suspend fun tangemPayProduceVirtualAccountData(
preflightReadFilter: PreflightReadFilter,
): Either<Throwable, VirtualAccountActivationData> {
return coroutineScope {
val result = runTaskAsyncReturnOnMain(
runnable = tangemPayVirtualAccountTaskFactory.create(coroutineScope = this),
cardId = null,
initialMessage = Message(resources.getStringSafe(R.string.initial_message_tap_header)),
preflightReadFilter = preflightReadFilter,
)
return@coroutineScope when (result) {
is CompletionResult.Failure<*> -> result.error.left()
is CompletionResult.Success<VirtualAccountActivationData> -> result.data.right()
}
}
}
override suspend fun getWithdrawalSignature( override suspend fun getWithdrawalSignature(
hash: String, hash: String,
preflightReadFilter: PreflightReadFilter, preflightReadFilter: PreflightReadFilter,

View file

@ -23,6 +23,7 @@ import com.tangem.domain.models.scan.ScanResponse
import com.tangem.domain.models.wallet.UserWalletId import com.tangem.domain.models.wallet.UserWalletId
import com.tangem.domain.pay.WithdrawalSignatureResult import com.tangem.domain.pay.WithdrawalSignatureResult
import com.tangem.domain.visa.model.TangemPayInitialCredentials import com.tangem.domain.visa.model.TangemPayInitialCredentials
import com.tangem.domain.visa.model.VirtualAccountActivationData
import com.tangem.domain.visa.model.VisaActivationInput import com.tangem.domain.visa.model.VisaActivationInput
import com.tangem.domain.visa.model.VisaDataForApprove import com.tangem.domain.visa.model.VisaDataForApprove
import com.tangem.domain.visa.model.VisaSignedDataByCustomerWallet import com.tangem.domain.visa.model.VisaSignedDataByCustomerWallet
@ -241,6 +242,12 @@ class MockTangemSdkManager(
error("Not implemented") error("Not implemented")
} }
override suspend fun tangemPayProduceVirtualAccountData(
preflightReadFilter: PreflightReadFilter,
): Either<Throwable, VirtualAccountActivationData> {
error("Not implemented")
}
override suspend fun getWithdrawalSignature( override suspend fun getWithdrawalSignature(
hash: String, hash: String,
preflightReadFilter: PreflightReadFilter, preflightReadFilter: PreflightReadFilter,

View file

@ -0,0 +1,80 @@
package com.tangem.tap.domain.tasks.visa
import com.tangem.common.CompletionResult
import com.tangem.common.card.CardWallet
import com.tangem.common.core.CardSession
import com.tangem.common.core.CardSessionRunnable
import com.tangem.common.core.CompletionCallback
import com.tangem.common.core.TangemSdkError
import com.tangem.common.extensions.toMapKey
import com.tangem.core.error.ext.tangemError
import com.tangem.crypto.hdWallet.bip32.ExtendedPublicKey
import com.tangem.domain.card.common.visa.VisaUtilities
import com.tangem.domain.visa.error.VisaActivationError
import com.tangem.domain.visa.model.VirtualAccountActivationData
import com.tangem.operations.derivation.DeriveWalletPublicKeyTask
import com.tangem.operations.derivation.ExtendedPublicKeysMap
import dagger.assisted.Assisted
import dagger.assisted.AssistedFactory
import dagger.assisted.AssistedInject
import kotlinx.coroutines.CompletableDeferred
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.launch
/**
* Derives the Virtual Account key ([VisaUtilities.virtualAccountDerivationPath]) on the card and
* generates its deposit address. The derived key is returned (keyed by the seed wallet public key)
* so the caller can persist it via `DerivationsRepository.storeDerivedKeys` no second tap needed.
*/
class TangemPayGenerateVirtualAccountAddressTask @AssistedInject constructor(
@Assisted private val coroutineScope: CoroutineScope,
) : CardSessionRunnable<VirtualAccountActivationData> {
override fun run(session: CardSession, callback: CompletionCallback<VirtualAccountActivationData>) {
coroutineScope.launch {
callback(runSuspend(session = session))
}
}
private suspend fun runSuspend(session: CardSession): CompletionResult<VirtualAccountActivationData> {
val card = session.environment.card ?: return CompletionResult.Failure(TangemSdkError.MissingPreflightRead())
val wallet = card.wallets.firstOrNull { it.curve == VisaUtilities.curve }
?: return CompletionResult.Failure(VisaActivationError.MissingWallet.tangemError)
val extendedPublicKey = when (val derivationResult = runDerivationTask(session, wallet)) {
is CompletionResult.Failure<*> -> return CompletionResult.Failure(derivationResult.error)
is CompletionResult.Success<ExtendedPublicKey> -> derivationResult.data
}
val address = VisaUtilities.generateAddressFromExtendedKey(extendedPublicKey = extendedPublicKey)
val derivedKeys = mapOf(
wallet.publicKey.toMapKey() to ExtendedPublicKeysMap(
mapOf(VisaUtilities.virtualAccountDerivationPath to extendedPublicKey),
),
)
return CompletionResult.Success(
data = VirtualAccountActivationData(address = address, derivedKeys = derivedKeys),
)
}
private suspend fun runDerivationTask(
session: CardSession,
wallet: CardWallet,
): CompletionResult<ExtendedPublicKey> {
val deferred = CompletableDeferred<CompletionResult<ExtendedPublicKey>>()
val derivationTask = DeriveWalletPublicKeyTask(
walletPublicKey = wallet.publicKey,
derivationPath = VisaUtilities.virtualAccountDerivationPath,
)
derivationTask.run(session = session, callback = deferred::complete)
return deferred.await()
}
@AssistedFactory
interface Factory {
fun create(coroutineScope: CoroutineScope): TangemPayGenerateVirtualAccountAddressTask
}
}

View file

@ -6,6 +6,7 @@ import com.tangem.domain.models.wallet.UserWallet
import com.tangem.domain.pay.WithdrawalSignatureResult import com.tangem.domain.pay.WithdrawalSignatureResult
import com.tangem.domain.pay.datasource.TangemPayAuthDataSource import com.tangem.domain.pay.datasource.TangemPayAuthDataSource
import com.tangem.domain.visa.model.TangemPayInitialCredentials import com.tangem.domain.visa.model.TangemPayInitialCredentials
import com.tangem.domain.visa.model.VirtualAccountActivationData
import com.tangem.sdk.api.TangemSdkManager import com.tangem.sdk.api.TangemSdkManager
import javax.inject.Inject import javax.inject.Inject
@ -26,6 +27,18 @@ internal class DefaultTangemPayAuthDataSource @Inject constructor(
} }
} }
override suspend fun produceVirtualAccountData(
userWallet: UserWallet,
): Either<Throwable, VirtualAccountActivationData> {
return when (userWallet) {
is UserWallet.Cold -> {
val preflightReadFilter = UserWalletIdPreflightReadFilter(userWallet.walletId)
tangemSdkManager.tangemPayProduceVirtualAccountData(preflightReadFilter = preflightReadFilter)
}
is UserWallet.Hot -> tangemPayHotSdkManager.produceVirtualAccountData(userWallet)
}
}
override suspend fun getWithdrawalSignature( override suspend fun getWithdrawalSignature(
userWallet: UserWallet, userWallet: UserWallet,
hash: String, hash: String,

View file

@ -5,6 +5,7 @@ import arrow.core.getOrElse
import arrow.core.raise.Raise import arrow.core.raise.Raise
import arrow.core.raise.either import arrow.core.raise.either
import com.tangem.common.extensions.hexToBytes import com.tangem.common.extensions.hexToBytes
import com.tangem.common.extensions.toMapKey
import com.tangem.core.error.ext.tangemError import com.tangem.core.error.ext.tangemError
import com.tangem.crypto.hdWallet.bip32.ExtendedPublicKey import com.tangem.crypto.hdWallet.bip32.ExtendedPublicKey
import com.tangem.domain.card.common.visa.VisaUtilities import com.tangem.domain.card.common.visa.VisaUtilities
@ -14,11 +15,13 @@ import com.tangem.domain.visa.datasource.TangemPayRemoteDataSource
import com.tangem.domain.visa.error.VisaActivationError import com.tangem.domain.visa.error.VisaActivationError
import com.tangem.domain.visa.error.VisaCardScanError import com.tangem.domain.visa.error.VisaCardScanError
import com.tangem.domain.visa.model.TangemPayInitialCredentials import com.tangem.domain.visa.model.TangemPayInitialCredentials
import com.tangem.domain.visa.model.VirtualAccountActivationData
import com.tangem.domain.wallets.hot.HotWalletAccessor import com.tangem.domain.wallets.hot.HotWalletAccessor
import com.tangem.hot.sdk.TangemHotSdk import com.tangem.hot.sdk.TangemHotSdk
import com.tangem.hot.sdk.model.DataToSign import com.tangem.hot.sdk.model.DataToSign
import com.tangem.hot.sdk.model.DeriveWalletRequest import com.tangem.hot.sdk.model.DeriveWalletRequest
import com.tangem.hot.sdk.model.UnlockHotWallet import com.tangem.hot.sdk.model.UnlockHotWallet
import com.tangem.operations.derivation.ExtendedPublicKeysMap
import javax.inject.Inject import javax.inject.Inject
internal class TangemPayHotSdkManager @Inject constructor( internal class TangemPayHotSdkManager @Inject constructor(
@ -56,6 +59,34 @@ internal class TangemPayHotSdkManager @Inject constructor(
) )
} }
suspend fun produceVirtualAccountData(hotWallet: UserWallet.Hot): Either<Throwable, VirtualAccountActivationData> =
withUnlockedHotWallet(hotWallet) { unlockHotWallet ->
val response = tangemHotSdk.derivePublicKey(
unlockHotWallet = unlockHotWallet,
request = DeriveWalletRequest(
requests = listOf(
DeriveWalletRequest.Request(
curve = VisaUtilities.curve,
paths = listOf(VisaUtilities.virtualAccountDerivationPath),
),
),
),
)
val curveResponse = response.responses.firstOrNull { it.curve == VisaUtilities.curve }
?: raise(VisaActivationError.MissingWallet.tangemError)
val extendedPublicKey = curveResponse.publicKeys[VisaUtilities.virtualAccountDerivationPath]
?: raise(VisaActivationError.MissingWallet.tangemError)
VirtualAccountActivationData(
address = VisaUtilities.generateAddressFromExtendedKey(extendedPublicKey),
derivedKeys = mapOf(
curveResponse.seedKey.publicKey.toMapKey() to ExtendedPublicKeysMap(
mapOf(VisaUtilities.virtualAccountDerivationPath to extendedPublicKey),
),
),
)
}
suspend fun getWithdrawalSignature( suspend fun getWithdrawalSignature(
hotWallet: UserWallet.Hot, hotWallet: UserWallet.Hot,
hash: String, hash: String,

View file

@ -8,6 +8,7 @@ import com.squareup.moshi.Moshi
import com.tangem.data.virtualaccount.converter.VirtualAccountStatusValueDMConverter import com.tangem.data.virtualaccount.converter.VirtualAccountStatusValueDMConverter
import com.tangem.data.virtualaccount.flow.DefaultVirtualAccountStatusFetcher import com.tangem.data.virtualaccount.flow.DefaultVirtualAccountStatusFetcher
import com.tangem.data.virtualaccount.flow.DefaultVirtualAccountStatusProducer import com.tangem.data.virtualaccount.flow.DefaultVirtualAccountStatusProducer
import com.tangem.data.virtualaccount.repository.DefaultVirtualAccountActivationRepository
import com.tangem.data.virtualaccount.store.VirtualAccountStatusesStore import com.tangem.data.virtualaccount.store.VirtualAccountStatusesStore
import com.tangem.datasource.di.NetworkMoshi import com.tangem.datasource.di.NetworkMoshi
import com.tangem.datasource.local.datastore.RuntimeSharedStore import com.tangem.datasource.local.datastore.RuntimeSharedStore
@ -17,6 +18,8 @@ import com.tangem.datasource.utils.mapWithStringKeyTypes
import com.tangem.domain.virtualaccount.flow.VirtualAccountStatusFetcher import com.tangem.domain.virtualaccount.flow.VirtualAccountStatusFetcher
import com.tangem.domain.virtualaccount.flow.VirtualAccountStatusProducer import com.tangem.domain.virtualaccount.flow.VirtualAccountStatusProducer
import com.tangem.domain.virtualaccount.flow.VirtualAccountStatusSupplier import com.tangem.domain.virtualaccount.flow.VirtualAccountStatusSupplier
import com.tangem.domain.virtualaccount.repository.VirtualAccountActivationRepository
import com.tangem.domain.virtualaccount.usecase.ActivateVirtualAccountUseCase
import com.tangem.utils.coroutines.AppCoroutineScope import com.tangem.utils.coroutines.AppCoroutineScope
import dagger.Binds import dagger.Binds
import dagger.Module import dagger.Module
@ -40,6 +43,12 @@ internal interface VirtualAccountDataModule {
@Singleton @Singleton
fun bindVirtualAccountStatusFetcher(impl: DefaultVirtualAccountStatusFetcher): VirtualAccountStatusFetcher fun bindVirtualAccountStatusFetcher(impl: DefaultVirtualAccountStatusFetcher): VirtualAccountStatusFetcher
@Binds
@Singleton
fun bindVirtualAccountActivationRepository(
impl: DefaultVirtualAccountActivationRepository,
): VirtualAccountActivationRepository
companion object { companion object {
@Provides @Provides
@ -77,5 +86,13 @@ internal interface VirtualAccountDataModule {
keyCreator = { "virtual_account_status_${it.userWalletId.stringValue}" }, keyCreator = { "virtual_account_status_${it.userWalletId.stringValue}" },
) {} ) {}
} }
@Provides
@Singleton
fun provideActivateVirtualAccountUseCase(
repository: VirtualAccountActivationRepository,
): ActivateVirtualAccountUseCase {
return ActivateVirtualAccountUseCase(repository = repository)
}
} }
} }

View file

@ -21,7 +21,6 @@ import com.tangem.domain.networks.single.SingleNetworkStatusProducer
import com.tangem.domain.networks.single.SingleNetworkStatusSupplier import com.tangem.domain.networks.single.SingleNetworkStatusSupplier
import com.tangem.domain.pay.TangemPayCurrencyFactory import com.tangem.domain.pay.TangemPayCurrencyFactory
import com.tangem.domain.virtualaccount.flow.VirtualAccountStatusFetcher import com.tangem.domain.virtualaccount.flow.VirtualAccountStatusFetcher
import com.tangem.domain.wallets.extension.hasDerivation
import com.tangem.utils.coroutines.CoroutineDispatcherProvider import com.tangem.utils.coroutines.CoroutineDispatcherProvider
import com.tangem.utils.logging.TangemLogger import com.tangem.utils.logging.TangemLogger
import java.math.BigDecimal import java.math.BigDecimal
@ -57,21 +56,9 @@ internal class DefaultVirtualAccountStatusFetcher @Inject constructor(
private suspend fun getBalance(userWalletId: UserWalletId): Either<VirtualAccountStatusValue, BigDecimal> { private suspend fun getBalance(userWalletId: UserWalletId): Either<VirtualAccountStatusValue, BigDecimal> {
val userWallet = userWalletsListRepository.getSyncStrict(userWalletId) val userWallet = userWalletsListRepository.getSyncStrict(userWalletId)
val hasVirtualAccountDerivation = userWallet.hasDerivation(
blockchain = VisaUtilities.visaBlockchain,
derivationPath = VisaUtilities.virtualAccountDerivationPath.rawPath,
)
if (!hasVirtualAccountDerivation) {
// TODO: Doston(VA) Derive will be implemented in [REDACTED_TASK_KEY]
TangemLogger.withTag(TAG).d("Virtual account is not derived")
return VirtualAccountStatusValue.Error.NotSynced.left()
}
val network = networkFactory.create( val network = networkFactory.create(
blockchain = VisaUtilities.visaBlockchain, blockchain = VisaUtilities.visaBlockchain,
derivationPath = derivationPath = Network.DerivationPath.Custom(VisaUtilities.virtualAccountDerivationPath.rawPath),
Network.DerivationPath.Custom(VisaUtilities.virtualAccountDerivationPath.rawPath),
userWallet = userWallet, userWallet = userWallet,
) )
if (network == null) { if (network == null) {

View file

@ -0,0 +1,38 @@
package com.tangem.data.virtualaccount.repository
import com.tangem.domain.common.wallets.UserWalletsListRepository
import com.tangem.domain.common.wallets.getSyncStrict
import com.tangem.domain.models.wallet.UserWalletId
import com.tangem.domain.pay.datasource.TangemPayAuthDataSource
import com.tangem.domain.virtualaccount.repository.VirtualAccountActivationRepository
import com.tangem.domain.wallets.derivations.DerivationsRepository
import com.tangem.utils.coroutines.CoroutineDispatcherProvider
import kotlinx.coroutines.withContext
import javax.inject.Inject
internal class DefaultVirtualAccountActivationRepository @Inject constructor(
private val authDataSource: TangemPayAuthDataSource,
private val derivationsRepository: DerivationsRepository,
private val userWalletsListRepository: UserWalletsListRepository,
private val dispatchers: CoroutineDispatcherProvider,
) : VirtualAccountActivationRepository {
override suspend fun activateVirtualAccount(userWalletId: UserWalletId) {
withContext(dispatchers.io) {
val userWallet = userWalletsListRepository.getSyncStrict(userWalletId)
val activationData = authDataSource.produceVirtualAccountData(userWallet)
.fold(
ifLeft = { error("Can not activate virtual account: ${it.message}") },
ifRight = { it },
)
// Persist the derived VA key so the on-chain balance can be read without re-deriving (no extra tap).
derivationsRepository.storeDerivedKeys(
userWalletId = userWalletId,
derivedKeys = activationData.derivedKeys,
)
// TODO([REDACTED_TASK_KEY]): register activationData.address with the VA backend once the endpoint is available.
}
}
}

View file

@ -13,24 +13,14 @@ import com.tangem.domain.networks.single.SingleNetworkStatusFetcher
import com.tangem.domain.networks.single.SingleNetworkStatusSupplier import com.tangem.domain.networks.single.SingleNetworkStatusSupplier
import com.tangem.domain.pay.TangemPayCurrencyFactory import com.tangem.domain.pay.TangemPayCurrencyFactory
import com.tangem.domain.virtualaccount.flow.VirtualAccountStatusFetcher import com.tangem.domain.virtualaccount.flow.VirtualAccountStatusFetcher
import com.tangem.domain.wallets.extension.hasDerivation
import com.tangem.utils.coroutines.TestingCoroutineDispatcherProvider import com.tangem.utils.coroutines.TestingCoroutineDispatcherProvider
import io.mockk.clearMocks import io.mockk.*
import io.mockk.coEvery
import io.mockk.coVerify
import io.mockk.every
import io.mockk.mockk
import io.mockk.mockkStatic
import io.mockk.unmockkStatic
import kotlinx.coroutines.ExperimentalCoroutinesApi import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.test.runTest import kotlinx.coroutines.test.runTest
import org.junit.jupiter.api.AfterEach
import org.junit.jupiter.api.BeforeEach import org.junit.jupiter.api.BeforeEach
import org.junit.jupiter.api.Test import org.junit.jupiter.api.Test
private const val USER_WALLET_EXTENSIONS = "com.tangem.domain.wallets.extension.UserWalletExtensionsKt"
@OptIn(ExperimentalCoroutinesApi::class) @OptIn(ExperimentalCoroutinesApi::class)
internal class DefaultVirtualAccountStatusFetcherTest { internal class DefaultVirtualAccountStatusFetcherTest {
@ -59,25 +49,40 @@ internal class DefaultVirtualAccountStatusFetcherTest {
@BeforeEach @BeforeEach
fun setUp() { fun setUp() {
mockkStatic(USER_WALLET_EXTENSIONS)
clearMocks(networkFactory, tangemPayCurrencyFactory, singleNetworkStatusFetcher, userWalletsListRepository) clearMocks(networkFactory, tangemPayCurrencyFactory, singleNetworkStatusFetcher, userWalletsListRepository)
every { userWalletsListRepository.userWallets } returns MutableStateFlow(listOf(userWallet)) every { userWalletsListRepository.userWallets } returns MutableStateFlow(listOf(userWallet))
every {
networkFactory.create(any<Blockchain>(), any<Network.DerivationPath>(), any<UserWallet>())
} returns network
every { tangemPayCurrencyFactory.createVirtualAccountToken(userWalletId) } returns token every { tangemPayCurrencyFactory.createVirtualAccountToken(userWalletId) } returns token
coEvery { singleNetworkStatusFetcher(any()) } returns Unit.right() coEvery { singleNetworkStatusFetcher(any()) } returns Unit.right()
} }
@AfterEach @Test
fun tearDown() { fun `GIVEN network created WHEN invoke THEN on-chain status fetched with VA token`() = runTest {
unmockkStatic(USER_WALLET_EXTENSIONS) // Arrange
every {
networkFactory.create(any<Blockchain>(), any<Network.DerivationPath>(), any<UserWallet>())
} returns network
// Act
fetcher.invoke(VirtualAccountStatusFetcher.Params(userWalletId))
// Assert
coVerify(exactly = 1) {
singleNetworkStatusFetcher(
SingleNetworkStatusFetcher.Params(
userWalletId = userWalletId,
network = network,
extraTokens = setOf(token),
),
)
}
} }
@Test @Test
fun `GIVEN VA derivation missing WHEN invoke THEN on-chain fetch skipped`() = runTest { fun `GIVEN network cannot be created WHEN invoke THEN on-chain fetch skipped`() = runTest {
// Arrange // Arrange
every { userWallet.hasDerivation(any(), any()) } returns false every {
networkFactory.create(any<Blockchain>(), any<Network.DerivationPath>(), any<UserWallet>())
} returns null
// Act // Act
fetcher.invoke(VirtualAccountStatusFetcher.Params(userWalletId)) fetcher.invoke(VirtualAccountStatusFetcher.Params(userWalletId))
@ -85,16 +90,4 @@ internal class DefaultVirtualAccountStatusFetcherTest {
// Assert // Assert
coVerify(exactly = 0) { singleNetworkStatusFetcher(any()) } coVerify(exactly = 0) { singleNetworkStatusFetcher(any()) }
} }
@Test
fun `GIVEN VA derivation present WHEN invoke THEN on-chain fetch performed`() = runTest {
// Arrange
every { userWallet.hasDerivation(any(), any()) } returns true
// Act
fetcher.invoke(VirtualAccountStatusFetcher.Params(userWalletId))
// Assert
coVerify(exactly = 1) { singleNetworkStatusFetcher(any()) }
}
} }

View file

@ -0,0 +1,79 @@
package com.tangem.data.virtualaccount.repository
import arrow.core.left
import arrow.core.right
import com.google.common.truth.Truth.assertThat
import com.tangem.common.extensions.ByteArrayKey
import com.tangem.domain.common.wallets.UserWalletsListRepository
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.visa.model.VirtualAccountActivationData
import com.tangem.domain.wallets.derivations.DerivationsRepository
import com.tangem.operations.derivation.ExtendedPublicKeysMap
import com.tangem.utils.coroutines.TestingCoroutineDispatcherProvider
import io.mockk.clearMocks
import io.mockk.coEvery
import io.mockk.coVerify
import io.mockk.every
import io.mockk.mockk
import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.test.runTest
import org.junit.jupiter.api.BeforeEach
import org.junit.jupiter.api.Test
@OptIn(ExperimentalCoroutinesApi::class)
internal class DefaultVirtualAccountActivationRepositoryTest {
private val authDataSource: TangemPayAuthDataSource = mockk()
private val derivationsRepository: DerivationsRepository = mockk(relaxUnitFun = true)
private val userWalletsListRepository: UserWalletsListRepository = mockk()
private val dispatchers = TestingCoroutineDispatcherProvider()
private val repository = DefaultVirtualAccountActivationRepository(
authDataSource = authDataSource,
derivationsRepository = derivationsRepository,
userWalletsListRepository = userWalletsListRepository,
dispatchers = dispatchers,
)
private val userWalletId = UserWalletId("011")
private val userWallet: UserWallet = mockk { every { walletId } returns userWalletId }
private val derivedKeys: Map<ByteArrayKey, ExtendedPublicKeysMap> = mapOf(
ByteArrayKey(byteArrayOf(1, 2, 3)) to ExtendedPublicKeysMap(emptyMap()),
)
private val activationData = VirtualAccountActivationData(address = "0xVA", derivedKeys = derivedKeys)
@BeforeEach
fun setUp() {
clearMocks(authDataSource, derivationsRepository, userWalletsListRepository)
every { userWalletsListRepository.userWallets } returns MutableStateFlow(listOf(userWallet))
}
@Test
fun `GIVEN datasource returns data WHEN activate THEN derived keys persisted`() = runTest {
// Arrange
coEvery { authDataSource.produceVirtualAccountData(userWallet) } returns activationData.right()
// Act
repository.activateVirtualAccount(userWalletId)
// Assert
coVerify(exactly = 1) { derivationsRepository.storeDerivedKeys(userWalletId, derivedKeys) }
}
@Test
fun `GIVEN datasource returns error WHEN activate THEN throws AND nothing persisted`() = runTest {
// Arrange
coEvery { authDataSource.produceVirtualAccountData(userWallet) } returns IllegalStateException("nope").left()
// Act
val error = runCatching { repository.activateVirtualAccount(userWalletId) }.exceptionOrNull()
// Assert
assertThat(error).isInstanceOf(IllegalStateException::class.java)
coVerify(exactly = 0) { derivationsRepository.storeDerivedKeys(any(), any()) }
}
}

View file

@ -100,6 +100,11 @@ internal class DefaultColdMapDerivationsRepository @Inject constructor(
} }
} }
override fun mergeDerivedKeys(
userWallet: UserWallet.Cold,
keys: Map<ByteArrayKey, ExtendedPublicKeysMap>,
): UserWallet.Cold = userWallet.updateDerivedKeys(keys)
override suspend fun hasMissedDerivations( override suspend fun hasMissedDerivations(
userWallet: UserWallet.Cold, userWallet: UserWallet.Cold,
networksWithDerivationPath: Map<BackendId, String?>, networksWithDerivationPath: Map<BackendId, String?>,

View file

@ -77,6 +77,24 @@ internal class DefaultDerivationsRepository @Inject constructor(
} }
} }
override suspend fun storeDerivedKeys(
userWalletId: UserWalletId,
derivedKeys: Map<ByteArrayKey, ExtendedPublicKeysMap>,
) {
if (derivedKeys.isEmpty()) {
TangemLogger.d("Nothing to store")
return
}
val userWallet = userWalletsListRepository.getSyncStrict(userWalletId)
val updatedUserWallet = when (userWallet) {
is UserWallet.Cold -> coldDerivationsRepository.mergeDerivedKeys(userWallet, derivedKeys)
is UserWallet.Hot -> hotDerivationsRepository.mergeDerivedKeys(userWallet, derivedKeys)
}
userWallet.update(updatedUserWallet)
}
override suspend fun getExistingDerivedKeys( override suspend fun getExistingDerivedKeys(
userWalletId: UserWalletId, userWalletId: UserWalletId,
seedKey: ByteArrayKey, seedKey: ByteArrayKey,

View file

@ -101,6 +101,11 @@ internal class DefaultHotMapDerivationsRepository @Inject constructor(
return updatedUserWallet.updateWithNewKeys(newKeys) to newKeys return updatedUserWallet.updateWithNewKeys(newKeys) to newKeys
} }
override fun mergeDerivedKeys(
userWallet: UserWallet.Hot,
keys: Map<ByteArrayKey, ExtendedPublicKeysMap>,
): UserWallet.Hot = userWallet.updateWithNewKeys(keys)
override suspend fun hasMissedDerivations( override suspend fun hasMissedDerivations(
userWallet: UserWallet.Hot, userWallet: UserWallet.Hot,
networksWithDerivationPath: Map<BackendId, String?>, networksWithDerivationPath: Map<BackendId, String?>,

View file

@ -15,4 +15,7 @@ dependencies {
/** Domain models */ /** Domain models */
implementation(projects.domain.models) implementation(projects.domain.models)
/** Tangem libraries (derived public keys types for VA activation) */
implementation(tangemDeps.card.core)
} }

View file

@ -0,0 +1,16 @@
package com.tangem.domain.visa.model
import com.tangem.common.extensions.ByteArrayKey
import com.tangem.operations.derivation.ExtendedPublicKeysMap
/**
* Result of deriving the Virtual Account key on the card.
*
* @property address the VA deposit address generated from the derived key
* @property derivedKeys the derived extended public key(s) keyed by the seed wallet public key,
* ready to be persisted into the wallet (see `DerivationsRepository.storeDerivedKeys`)
*/
data class VirtualAccountActivationData(
val address: String,
val derivedKeys: Map<ByteArrayKey, ExtendedPublicKeysMap>,
)

View file

@ -4,11 +4,14 @@ import arrow.core.Either
import com.tangem.domain.models.wallet.UserWallet import com.tangem.domain.models.wallet.UserWallet
import com.tangem.domain.pay.WithdrawalSignatureResult import com.tangem.domain.pay.WithdrawalSignatureResult
import com.tangem.domain.visa.model.TangemPayInitialCredentials import com.tangem.domain.visa.model.TangemPayInitialCredentials
import com.tangem.domain.visa.model.VirtualAccountActivationData
interface TangemPayAuthDataSource { interface TangemPayAuthDataSource {
suspend fun produceInitialCredentials(userWallet: UserWallet): Either<Throwable, TangemPayInitialCredentials> suspend fun produceInitialCredentials(userWallet: UserWallet): Either<Throwable, TangemPayInitialCredentials>
suspend fun produceVirtualAccountData(userWallet: UserWallet): Either<Throwable, VirtualAccountActivationData>
suspend fun getWithdrawalSignature( suspend fun getWithdrawalSignature(
userWallet: UserWallet, userWallet: UserWallet,
hash: String, hash: String,

View file

@ -0,0 +1,13 @@
package com.tangem.domain.virtualaccount.repository
import com.tangem.domain.models.wallet.UserWalletId
interface VirtualAccountActivationRepository {
/**
* Derives the Virtual Account key on the card (NFC) and persists it into the wallet, so the
* on-chain VA balance can later be fetched without re-deriving. Throws on failure.
*/
@Throws
suspend fun activateVirtualAccount(userWalletId: UserWalletId)
}

View file

@ -0,0 +1,17 @@
package com.tangem.domain.virtualaccount.usecase
import arrow.core.Either
import arrow.core.Either.Companion.catch
import com.tangem.domain.models.wallet.UserWalletId
import com.tangem.domain.virtualaccount.repository.VirtualAccountActivationRepository
class ActivateVirtualAccountUseCase(
private val repository: VirtualAccountActivationRepository,
) {
suspend operator fun invoke(userWalletId: UserWalletId): Either<Throwable, Unit> {
return catch {
repository.activateVirtualAccount(userWalletId)
}
}
}

View file

@ -27,6 +27,9 @@ interface ColdMapDerivationsRepository {
derivations: Map<ByteArrayKey, List<DerivationPath>>, derivations: Map<ByteArrayKey, List<DerivationPath>>,
): Pair<UserWallet.Cold, Map<ByteArrayKey, ExtendedPublicKeysMap>> ): Pair<UserWallet.Cold, Map<ByteArrayKey, ExtendedPublicKeysMap>>
/** Merges already-derived [keys] into [userWallet]'s stored derivations without deriving on the card. */
fun mergeDerivedKeys(userWallet: UserWallet.Cold, keys: Map<ByteArrayKey, ExtendedPublicKeysMap>): UserWallet.Cold
/** Check if user [userWallet] has missed derivations using map of [Network.ID] with extraDerivationPath */ /** Check if user [userWallet] has missed derivations using map of [Network.ID] with extraDerivationPath */
suspend fun hasMissedDerivations( suspend fun hasMissedDerivations(
userWallet: UserWallet.Cold, userWallet: UserWallet.Cold,

View file

@ -29,6 +29,14 @@ interface DerivationsRepository {
derivations: Map<ByteArrayKey, List<DerivationPath>>, derivations: Map<ByteArrayKey, List<DerivationPath>>,
): Map<ByteArrayKey, ExtendedPublicKeysMap> ): Map<ByteArrayKey, ExtendedPublicKeysMap>
/**
* Merges already-derived [derivedKeys] into the wallet's stored derivations and persists it.
* Does NOT derive on the card (no NFC): use it to save a key that was obtained by a dedicated
* card task. Keyed by the seed wallet public key ([ByteArrayKey]).
*/
@Throws
suspend fun storeDerivedKeys(userWalletId: UserWalletId, derivedKeys: Map<ByteArrayKey, ExtendedPublicKeysMap>)
/** Returns already derived extended public keys for the given [seedKey] */ /** Returns already derived extended public keys for the given [seedKey] */
suspend fun getExistingDerivedKeys(userWalletId: UserWalletId, seedKey: ByteArrayKey): ExtendedPublicKeysMap suspend fun getExistingDerivedKeys(userWalletId: UserWalletId, seedKey: ByteArrayKey): ExtendedPublicKeysMap

View file

@ -29,6 +29,9 @@ interface HotMapDerivationsRepository {
derivations: Map<ByteArrayKey, List<DerivationPath>>, derivations: Map<ByteArrayKey, List<DerivationPath>>,
): Pair<UserWallet.Hot, Map<ByteArrayKey, ExtendedPublicKeysMap>> ): Pair<UserWallet.Hot, Map<ByteArrayKey, ExtendedPublicKeysMap>>
/** Merges already-derived [keys] into [userWallet]'s stored derivations. */
fun mergeDerivedKeys(userWallet: UserWallet.Hot, keys: Map<ByteArrayKey, ExtendedPublicKeysMap>): UserWallet.Hot
/** Check if user [userWallet] has missed derivations using map of [Network.ID] with extraDerivationPath */ /** Check if user [userWallet] has missed derivations using map of [Network.ID] with extraDerivationPath */
suspend fun hasMissedDerivations( suspend fun hasMissedDerivations(
userWallet: UserWallet.Hot, userWallet: UserWallet.Hot,

View file

@ -20,6 +20,7 @@ import com.tangem.domain.models.scan.ScanResponse
import com.tangem.domain.models.wallet.UserWalletId import com.tangem.domain.models.wallet.UserWalletId
import com.tangem.domain.pay.WithdrawalSignatureResult import com.tangem.domain.pay.WithdrawalSignatureResult
import com.tangem.domain.visa.model.TangemPayInitialCredentials import com.tangem.domain.visa.model.TangemPayInitialCredentials
import com.tangem.domain.visa.model.VirtualAccountActivationData
import com.tangem.domain.visa.model.VisaActivationInput import com.tangem.domain.visa.model.VisaActivationInput
import com.tangem.domain.visa.model.VisaDataForApprove import com.tangem.domain.visa.model.VisaDataForApprove
import com.tangem.domain.visa.model.VisaSignedDataByCustomerWallet import com.tangem.domain.visa.model.VisaSignedDataByCustomerWallet
@ -175,6 +176,10 @@ interface TangemSdkManager {
preflightReadFilter: PreflightReadFilter, preflightReadFilter: PreflightReadFilter,
): Either<Throwable, TangemPayInitialCredentials> ): Either<Throwable, TangemPayInitialCredentials>
suspend fun tangemPayProduceVirtualAccountData(
preflightReadFilter: PreflightReadFilter,
): Either<Throwable, VirtualAccountActivationData>
suspend fun getWithdrawalSignature( suspend fun getWithdrawalSignature(
hash: String, hash: String,
preflightReadFilter: PreflightReadFilter, preflightReadFilter: PreflightReadFilter,