diff --git a/app/src/main/java/com/tangem/tap/di/TangemSdkManagerModule.kt b/app/src/main/java/com/tangem/tap/di/TangemSdkManagerModule.kt index 37929d7d54..e24a130c14 100644 --- a/app/src/main/java/com/tangem/tap/di/TangemSdkManagerModule.kt +++ b/app/src/main/java/com/tangem/tap/di/TangemSdkManagerModule.kt @@ -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.MockTangemSdkManager 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.visa.VisaCardScanHandler import dagger.Module @@ -31,6 +32,7 @@ internal class TangemSdkManagerModule { visaCardScanHandler: VisaCardScanHandler, visaCardActivationTaskFactory: VisaCardActivationTask.Factory, tangemPayChallengeTaskFactory: TangemPayGenerateAddressAndSignChallengeTask.Factory, + tangemPayVirtualAccountTaskFactory: TangemPayGenerateVirtualAccountAddressTask.Factory, onboardingV2FeatureToggles: OnboardingV2FeatureToggles, analyticsErrorHandler: AnalyticsErrorHandler, cardRepository: CardRepository, @@ -44,6 +46,7 @@ internal class TangemSdkManagerModule { visaCardScanHandler = visaCardScanHandler, visaCardActivationTaskFactory = visaCardActivationTaskFactory, tangemPayChallengeTaskFactory = tangemPayChallengeTaskFactory, + tangemPayVirtualAccountTaskFactory = tangemPayVirtualAccountTaskFactory, onboardingV2FeatureToggles = onboardingV2FeatureToggles, analyticsErrorHandler = analyticsErrorHandler, cardRepository = cardRepository, diff --git a/app/src/main/java/com/tangem/tap/domain/sdk/impl/DefaultTangemSdkManager.kt b/app/src/main/java/com/tangem/tap/domain/sdk/impl/DefaultTangemSdkManager.kt index 3d5616cc54..0a6e31fb97 100644 --- a/app/src/main/java/com/tangem/tap/domain/sdk/impl/DefaultTangemSdkManager.kt +++ b/app/src/main/java/com/tangem/tap/domain/sdk/impl/DefaultTangemSdkManager.kt @@ -50,6 +50,7 @@ import com.tangem.tap.common.analytics.events.TangemSdkErrorEvent import com.tangem.tap.common.analytics.paramsInterceptor.CardContextInterceptor import com.tangem.tap.domain.tasks.product.* 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.VisaCardActivationTask import com.tangem.tap.domain.tasks.visa.VisaCustomerWalletApproveTask @@ -72,6 +73,7 @@ internal class DefaultTangemSdkManager( private val visaCardScanHandler: VisaCardScanHandler, private val visaCardActivationTaskFactory: VisaCardActivationTask.Factory, private val tangemPayChallengeTaskFactory: TangemPayGenerateAddressAndSignChallengeTask.Factory, + private val tangemPayVirtualAccountTaskFactory: TangemPayGenerateVirtualAccountAddressTask.Factory, private val onboardingV2FeatureToggles: OnboardingV2FeatureToggles, private val analyticsErrorHandler: AnalyticsErrorHandler, private val cardRepository: CardRepository, @@ -531,6 +533,24 @@ internal class DefaultTangemSdkManager( } } + override suspend fun tangemPayProduceVirtualAccountData( + preflightReadFilter: PreflightReadFilter, + ): Either { + 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 -> result.data.right() + } + } + } + override suspend fun getWithdrawalSignature( hash: String, preflightReadFilter: PreflightReadFilter, diff --git a/app/src/main/java/com/tangem/tap/domain/sdk/impl/MockTangemSdkManager.kt b/app/src/main/java/com/tangem/tap/domain/sdk/impl/MockTangemSdkManager.kt index 46568885f1..fa901dd6fd 100644 --- a/app/src/main/java/com/tangem/tap/domain/sdk/impl/MockTangemSdkManager.kt +++ b/app/src/main/java/com/tangem/tap/domain/sdk/impl/MockTangemSdkManager.kt @@ -23,6 +23,7 @@ import com.tangem.domain.models.scan.ScanResponse import com.tangem.domain.models.wallet.UserWalletId import com.tangem.domain.pay.WithdrawalSignatureResult 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.VisaDataForApprove import com.tangem.domain.visa.model.VisaSignedDataByCustomerWallet @@ -241,6 +242,12 @@ class MockTangemSdkManager( error("Not implemented") } + override suspend fun tangemPayProduceVirtualAccountData( + preflightReadFilter: PreflightReadFilter, + ): Either { + error("Not implemented") + } + override suspend fun getWithdrawalSignature( hash: String, preflightReadFilter: PreflightReadFilter, diff --git a/app/src/main/java/com/tangem/tap/domain/tasks/visa/TangemPayGenerateVirtualAccountAddressTask.kt b/app/src/main/java/com/tangem/tap/domain/tasks/visa/TangemPayGenerateVirtualAccountAddressTask.kt new file mode 100644 index 0000000000..cbc6dce5f6 --- /dev/null +++ b/app/src/main/java/com/tangem/tap/domain/tasks/visa/TangemPayGenerateVirtualAccountAddressTask.kt @@ -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 { + + override fun run(session: CardSession, callback: CompletionCallback) { + coroutineScope.launch { + callback(runSuspend(session = session)) + } + } + + private suspend fun runSuspend(session: CardSession): CompletionResult { + 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 -> 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 { + val deferred = CompletableDeferred>() + 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 + } +} \ No newline at end of file diff --git a/data/visa/src/main/kotlin/com/tangem/data/pay/datasource/DefaultTangemPayAuthDataSource.kt b/data/visa/src/main/kotlin/com/tangem/data/pay/datasource/DefaultTangemPayAuthDataSource.kt index 8198c181ce..f69787ee69 100644 --- a/data/visa/src/main/kotlin/com/tangem/data/pay/datasource/DefaultTangemPayAuthDataSource.kt +++ b/data/visa/src/main/kotlin/com/tangem/data/pay/datasource/DefaultTangemPayAuthDataSource.kt @@ -6,6 +6,7 @@ import com.tangem.domain.models.wallet.UserWallet import com.tangem.domain.pay.WithdrawalSignatureResult import com.tangem.domain.pay.datasource.TangemPayAuthDataSource import com.tangem.domain.visa.model.TangemPayInitialCredentials +import com.tangem.domain.visa.model.VirtualAccountActivationData import com.tangem.sdk.api.TangemSdkManager import javax.inject.Inject @@ -26,6 +27,18 @@ internal class DefaultTangemPayAuthDataSource @Inject constructor( } } + override suspend fun produceVirtualAccountData( + userWallet: UserWallet, + ): Either { + 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( userWallet: UserWallet, hash: String, diff --git a/data/visa/src/main/kotlin/com/tangem/data/pay/datasource/TangemPayHotSdkManager.kt b/data/visa/src/main/kotlin/com/tangem/data/pay/datasource/TangemPayHotSdkManager.kt index 6c2eac2122..6e79a073b5 100644 --- a/data/visa/src/main/kotlin/com/tangem/data/pay/datasource/TangemPayHotSdkManager.kt +++ b/data/visa/src/main/kotlin/com/tangem/data/pay/datasource/TangemPayHotSdkManager.kt @@ -5,6 +5,7 @@ import arrow.core.getOrElse import arrow.core.raise.Raise import arrow.core.raise.either import com.tangem.common.extensions.hexToBytes +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 @@ -14,11 +15,13 @@ import com.tangem.domain.visa.datasource.TangemPayRemoteDataSource import com.tangem.domain.visa.error.VisaActivationError import com.tangem.domain.visa.error.VisaCardScanError import com.tangem.domain.visa.model.TangemPayInitialCredentials +import com.tangem.domain.visa.model.VirtualAccountActivationData import com.tangem.domain.wallets.hot.HotWalletAccessor import com.tangem.hot.sdk.TangemHotSdk import com.tangem.hot.sdk.model.DataToSign import com.tangem.hot.sdk.model.DeriveWalletRequest import com.tangem.hot.sdk.model.UnlockHotWallet +import com.tangem.operations.derivation.ExtendedPublicKeysMap import javax.inject.Inject internal class TangemPayHotSdkManager @Inject constructor( @@ -56,6 +59,34 @@ internal class TangemPayHotSdkManager @Inject constructor( ) } + suspend fun produceVirtualAccountData(hotWallet: UserWallet.Hot): Either = + 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( hotWallet: UserWallet.Hot, hash: String, diff --git a/data/visa/src/main/kotlin/com/tangem/data/virtualaccount/di/VirtualAccountDataModule.kt b/data/visa/src/main/kotlin/com/tangem/data/virtualaccount/di/VirtualAccountDataModule.kt index 117dff144b..a85c3e8c6d 100644 --- a/data/visa/src/main/kotlin/com/tangem/data/virtualaccount/di/VirtualAccountDataModule.kt +++ b/data/visa/src/main/kotlin/com/tangem/data/virtualaccount/di/VirtualAccountDataModule.kt @@ -8,6 +8,7 @@ import com.squareup.moshi.Moshi import com.tangem.data.virtualaccount.converter.VirtualAccountStatusValueDMConverter import com.tangem.data.virtualaccount.flow.DefaultVirtualAccountStatusFetcher import com.tangem.data.virtualaccount.flow.DefaultVirtualAccountStatusProducer +import com.tangem.data.virtualaccount.repository.DefaultVirtualAccountActivationRepository import com.tangem.data.virtualaccount.store.VirtualAccountStatusesStore import com.tangem.datasource.di.NetworkMoshi 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.VirtualAccountStatusProducer 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 dagger.Binds import dagger.Module @@ -40,6 +43,12 @@ internal interface VirtualAccountDataModule { @Singleton fun bindVirtualAccountStatusFetcher(impl: DefaultVirtualAccountStatusFetcher): VirtualAccountStatusFetcher + @Binds + @Singleton + fun bindVirtualAccountActivationRepository( + impl: DefaultVirtualAccountActivationRepository, + ): VirtualAccountActivationRepository + companion object { @Provides @@ -77,5 +86,13 @@ internal interface VirtualAccountDataModule { keyCreator = { "virtual_account_status_${it.userWalletId.stringValue}" }, ) {} } + + @Provides + @Singleton + fun provideActivateVirtualAccountUseCase( + repository: VirtualAccountActivationRepository, + ): ActivateVirtualAccountUseCase { + return ActivateVirtualAccountUseCase(repository = repository) + } } } \ No newline at end of file diff --git a/data/visa/src/main/kotlin/com/tangem/data/virtualaccount/flow/DefaultVirtualAccountStatusFetcher.kt b/data/visa/src/main/kotlin/com/tangem/data/virtualaccount/flow/DefaultVirtualAccountStatusFetcher.kt index 6c0249af86..6ebb61f4c5 100644 --- a/data/visa/src/main/kotlin/com/tangem/data/virtualaccount/flow/DefaultVirtualAccountStatusFetcher.kt +++ b/data/visa/src/main/kotlin/com/tangem/data/virtualaccount/flow/DefaultVirtualAccountStatusFetcher.kt @@ -21,7 +21,6 @@ import com.tangem.domain.networks.single.SingleNetworkStatusProducer import com.tangem.domain.networks.single.SingleNetworkStatusSupplier import com.tangem.domain.pay.TangemPayCurrencyFactory import com.tangem.domain.virtualaccount.flow.VirtualAccountStatusFetcher -import com.tangem.domain.wallets.extension.hasDerivation import com.tangem.utils.coroutines.CoroutineDispatcherProvider import com.tangem.utils.logging.TangemLogger import java.math.BigDecimal @@ -57,21 +56,9 @@ internal class DefaultVirtualAccountStatusFetcher @Inject constructor( private suspend fun getBalance(userWalletId: UserWalletId): Either { 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( blockchain = VisaUtilities.visaBlockchain, - derivationPath = - Network.DerivationPath.Custom(VisaUtilities.virtualAccountDerivationPath.rawPath), + derivationPath = Network.DerivationPath.Custom(VisaUtilities.virtualAccountDerivationPath.rawPath), userWallet = userWallet, ) if (network == null) { diff --git a/data/visa/src/main/kotlin/com/tangem/data/virtualaccount/repository/DefaultVirtualAccountActivationRepository.kt b/data/visa/src/main/kotlin/com/tangem/data/virtualaccount/repository/DefaultVirtualAccountActivationRepository.kt new file mode 100644 index 0000000000..65845d940a --- /dev/null +++ b/data/visa/src/main/kotlin/com/tangem/data/virtualaccount/repository/DefaultVirtualAccountActivationRepository.kt @@ -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. + } + } +} \ No newline at end of file diff --git a/data/visa/src/test/kotlin/com/tangem/data/virtualaccount/flow/DefaultVirtualAccountStatusFetcherTest.kt b/data/visa/src/test/kotlin/com/tangem/data/virtualaccount/flow/DefaultVirtualAccountStatusFetcherTest.kt index 2960eaa74b..e578dcb5be 100644 --- a/data/visa/src/test/kotlin/com/tangem/data/virtualaccount/flow/DefaultVirtualAccountStatusFetcherTest.kt +++ b/data/visa/src/test/kotlin/com/tangem/data/virtualaccount/flow/DefaultVirtualAccountStatusFetcherTest.kt @@ -13,24 +13,14 @@ import com.tangem.domain.networks.single.SingleNetworkStatusFetcher import com.tangem.domain.networks.single.SingleNetworkStatusSupplier import com.tangem.domain.pay.TangemPayCurrencyFactory import com.tangem.domain.virtualaccount.flow.VirtualAccountStatusFetcher -import com.tangem.domain.wallets.extension.hasDerivation 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 io.mockk.mockkStatic -import io.mockk.unmockkStatic +import io.mockk.* import kotlinx.coroutines.ExperimentalCoroutinesApi import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.test.runTest -import org.junit.jupiter.api.AfterEach import org.junit.jupiter.api.BeforeEach import org.junit.jupiter.api.Test -private const val USER_WALLET_EXTENSIONS = "com.tangem.domain.wallets.extension.UserWalletExtensionsKt" - @OptIn(ExperimentalCoroutinesApi::class) internal class DefaultVirtualAccountStatusFetcherTest { @@ -59,25 +49,40 @@ internal class DefaultVirtualAccountStatusFetcherTest { @BeforeEach fun setUp() { - mockkStatic(USER_WALLET_EXTENSIONS) clearMocks(networkFactory, tangemPayCurrencyFactory, singleNetworkStatusFetcher, userWalletsListRepository) every { userWalletsListRepository.userWallets } returns MutableStateFlow(listOf(userWallet)) - every { - networkFactory.create(any(), any(), any()) - } returns network every { tangemPayCurrencyFactory.createVirtualAccountToken(userWalletId) } returns token coEvery { singleNetworkStatusFetcher(any()) } returns Unit.right() } - @AfterEach - fun tearDown() { - unmockkStatic(USER_WALLET_EXTENSIONS) + @Test + fun `GIVEN network created WHEN invoke THEN on-chain status fetched with VA token`() = runTest { + // Arrange + every { + networkFactory.create(any(), any(), any()) + } returns network + + // Act + fetcher.invoke(VirtualAccountStatusFetcher.Params(userWalletId)) + + // Assert + coVerify(exactly = 1) { + singleNetworkStatusFetcher( + SingleNetworkStatusFetcher.Params( + userWalletId = userWalletId, + network = network, + extraTokens = setOf(token), + ), + ) + } } @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 - every { userWallet.hasDerivation(any(), any()) } returns false + every { + networkFactory.create(any(), any(), any()) + } returns null // Act fetcher.invoke(VirtualAccountStatusFetcher.Params(userWalletId)) @@ -85,16 +90,4 @@ internal class DefaultVirtualAccountStatusFetcherTest { // Assert 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()) } - } } \ No newline at end of file diff --git a/data/visa/src/test/kotlin/com/tangem/data/virtualaccount/repository/DefaultVirtualAccountActivationRepositoryTest.kt b/data/visa/src/test/kotlin/com/tangem/data/virtualaccount/repository/DefaultVirtualAccountActivationRepositoryTest.kt new file mode 100644 index 0000000000..88f4fa2da5 --- /dev/null +++ b/data/visa/src/test/kotlin/com/tangem/data/virtualaccount/repository/DefaultVirtualAccountActivationRepositoryTest.kt @@ -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 = 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()) } + } +} \ No newline at end of file diff --git a/data/wallets/src/main/java/com/tangem/data/wallets/cold/DefaultColdMapDerivationsRepository.kt b/data/wallets/src/main/java/com/tangem/data/wallets/cold/DefaultColdMapDerivationsRepository.kt index b141b3b5ca..8f8ab2cfaa 100644 --- a/data/wallets/src/main/java/com/tangem/data/wallets/cold/DefaultColdMapDerivationsRepository.kt +++ b/data/wallets/src/main/java/com/tangem/data/wallets/cold/DefaultColdMapDerivationsRepository.kt @@ -100,6 +100,11 @@ internal class DefaultColdMapDerivationsRepository @Inject constructor( } } + override fun mergeDerivedKeys( + userWallet: UserWallet.Cold, + keys: Map, + ): UserWallet.Cold = userWallet.updateDerivedKeys(keys) + override suspend fun hasMissedDerivations( userWallet: UserWallet.Cold, networksWithDerivationPath: Map, diff --git a/data/wallets/src/main/java/com/tangem/data/wallets/derivations/DefaultDerivationsRepository.kt b/data/wallets/src/main/java/com/tangem/data/wallets/derivations/DefaultDerivationsRepository.kt index 3628a45990..37dc47defe 100644 --- a/data/wallets/src/main/java/com/tangem/data/wallets/derivations/DefaultDerivationsRepository.kt +++ b/data/wallets/src/main/java/com/tangem/data/wallets/derivations/DefaultDerivationsRepository.kt @@ -77,6 +77,24 @@ internal class DefaultDerivationsRepository @Inject constructor( } } + override suspend fun storeDerivedKeys( + userWalletId: UserWalletId, + derivedKeys: Map, + ) { + 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( userWalletId: UserWalletId, seedKey: ByteArrayKey, diff --git a/data/wallets/src/main/java/com/tangem/data/wallets/hot/DefaultHotMapDerivationsRepository.kt b/data/wallets/src/main/java/com/tangem/data/wallets/hot/DefaultHotMapDerivationsRepository.kt index 3e0a758cab..91d94a7313 100644 --- a/data/wallets/src/main/java/com/tangem/data/wallets/hot/DefaultHotMapDerivationsRepository.kt +++ b/data/wallets/src/main/java/com/tangem/data/wallets/hot/DefaultHotMapDerivationsRepository.kt @@ -101,6 +101,11 @@ internal class DefaultHotMapDerivationsRepository @Inject constructor( return updatedUserWallet.updateWithNewKeys(newKeys) to newKeys } + override fun mergeDerivedKeys( + userWallet: UserWallet.Hot, + keys: Map, + ): UserWallet.Hot = userWallet.updateWithNewKeys(keys) + override suspend fun hasMissedDerivations( userWallet: UserWallet.Hot, networksWithDerivationPath: Map, diff --git a/domain/visa/models/build.gradle.kts b/domain/visa/models/build.gradle.kts index e528d11260..9d9560e9a9 100644 --- a/domain/visa/models/build.gradle.kts +++ b/domain/visa/models/build.gradle.kts @@ -15,4 +15,7 @@ dependencies { /** Domain models */ implementation(projects.domain.models) + + /** Tangem libraries (derived public keys types for VA activation) */ + implementation(tangemDeps.card.core) } \ No newline at end of file diff --git a/domain/visa/models/src/main/kotlin/com/tangem/domain/visa/model/VirtualAccountActivationData.kt b/domain/visa/models/src/main/kotlin/com/tangem/domain/visa/model/VirtualAccountActivationData.kt new file mode 100644 index 0000000000..01f01c9459 --- /dev/null +++ b/domain/visa/models/src/main/kotlin/com/tangem/domain/visa/model/VirtualAccountActivationData.kt @@ -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, +) \ No newline at end of file diff --git a/domain/visa/src/main/kotlin/com/tangem/domain/pay/datasource/TangemPayAuthDataSource.kt b/domain/visa/src/main/kotlin/com/tangem/domain/pay/datasource/TangemPayAuthDataSource.kt index 4ab30e9ff7..f9e43dc3c8 100644 --- a/domain/visa/src/main/kotlin/com/tangem/domain/pay/datasource/TangemPayAuthDataSource.kt +++ b/domain/visa/src/main/kotlin/com/tangem/domain/pay/datasource/TangemPayAuthDataSource.kt @@ -4,11 +4,14 @@ import arrow.core.Either import com.tangem.domain.models.wallet.UserWallet import com.tangem.domain.pay.WithdrawalSignatureResult import com.tangem.domain.visa.model.TangemPayInitialCredentials +import com.tangem.domain.visa.model.VirtualAccountActivationData interface TangemPayAuthDataSource { suspend fun produceInitialCredentials(userWallet: UserWallet): Either + suspend fun produceVirtualAccountData(userWallet: UserWallet): Either + suspend fun getWithdrawalSignature( userWallet: UserWallet, hash: String, diff --git a/domain/visa/src/main/kotlin/com/tangem/domain/virtualaccount/repository/VirtualAccountActivationRepository.kt b/domain/visa/src/main/kotlin/com/tangem/domain/virtualaccount/repository/VirtualAccountActivationRepository.kt new file mode 100644 index 0000000000..4b1d46f487 --- /dev/null +++ b/domain/visa/src/main/kotlin/com/tangem/domain/virtualaccount/repository/VirtualAccountActivationRepository.kt @@ -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) +} \ No newline at end of file diff --git a/domain/visa/src/main/kotlin/com/tangem/domain/virtualaccount/usecase/ActivateVirtualAccountUseCase.kt b/domain/visa/src/main/kotlin/com/tangem/domain/virtualaccount/usecase/ActivateVirtualAccountUseCase.kt new file mode 100644 index 0000000000..dc7db9d27b --- /dev/null +++ b/domain/visa/src/main/kotlin/com/tangem/domain/virtualaccount/usecase/ActivateVirtualAccountUseCase.kt @@ -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 { + return catch { + repository.activateVirtualAccount(userWalletId) + } + } +} \ No newline at end of file diff --git a/domain/wallets/src/main/java/com/tangem/domain/wallets/derivations/ColdMapDerivationsRepository.kt b/domain/wallets/src/main/java/com/tangem/domain/wallets/derivations/ColdMapDerivationsRepository.kt index 86da09c677..b33b71e5eb 100644 --- a/domain/wallets/src/main/java/com/tangem/domain/wallets/derivations/ColdMapDerivationsRepository.kt +++ b/domain/wallets/src/main/java/com/tangem/domain/wallets/derivations/ColdMapDerivationsRepository.kt @@ -27,6 +27,9 @@ interface ColdMapDerivationsRepository { derivations: Map>, ): Pair> + /** Merges already-derived [keys] into [userWallet]'s stored derivations without deriving on the card. */ + fun mergeDerivedKeys(userWallet: UserWallet.Cold, keys: Map): UserWallet.Cold + /** Check if user [userWallet] has missed derivations using map of [Network.ID] with extraDerivationPath */ suspend fun hasMissedDerivations( userWallet: UserWallet.Cold, diff --git a/domain/wallets/src/main/java/com/tangem/domain/wallets/derivations/DerivationsRepository.kt b/domain/wallets/src/main/java/com/tangem/domain/wallets/derivations/DerivationsRepository.kt index a3ee510fde..8f9b139f46 100644 --- a/domain/wallets/src/main/java/com/tangem/domain/wallets/derivations/DerivationsRepository.kt +++ b/domain/wallets/src/main/java/com/tangem/domain/wallets/derivations/DerivationsRepository.kt @@ -29,6 +29,14 @@ interface DerivationsRepository { derivations: Map>, ): Map + /** + * 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) + /** Returns already derived extended public keys for the given [seedKey] */ suspend fun getExistingDerivedKeys(userWalletId: UserWalletId, seedKey: ByteArrayKey): ExtendedPublicKeysMap diff --git a/domain/wallets/src/main/java/com/tangem/domain/wallets/derivations/HotMapDerivationsRepository.kt b/domain/wallets/src/main/java/com/tangem/domain/wallets/derivations/HotMapDerivationsRepository.kt index 27b260db1d..f97950bf8b 100644 --- a/domain/wallets/src/main/java/com/tangem/domain/wallets/derivations/HotMapDerivationsRepository.kt +++ b/domain/wallets/src/main/java/com/tangem/domain/wallets/derivations/HotMapDerivationsRepository.kt @@ -29,6 +29,9 @@ interface HotMapDerivationsRepository { derivations: Map>, ): Pair> + /** Merges already-derived [keys] into [userWallet]'s stored derivations. */ + fun mergeDerivedKeys(userWallet: UserWallet.Hot, keys: Map): UserWallet.Hot + /** Check if user [userWallet] has missed derivations using map of [Network.ID] with extraDerivationPath */ suspend fun hasMissedDerivations( userWallet: UserWallet.Hot, diff --git a/libs/tangem-sdk-api/src/main/kotlin/com/tangem/sdk/api/TangemSdkManager.kt b/libs/tangem-sdk-api/src/main/kotlin/com/tangem/sdk/api/TangemSdkManager.kt index ba87204980..97b2ebe3a8 100644 --- a/libs/tangem-sdk-api/src/main/kotlin/com/tangem/sdk/api/TangemSdkManager.kt +++ b/libs/tangem-sdk-api/src/main/kotlin/com/tangem/sdk/api/TangemSdkManager.kt @@ -20,6 +20,7 @@ import com.tangem.domain.models.scan.ScanResponse import com.tangem.domain.models.wallet.UserWalletId import com.tangem.domain.pay.WithdrawalSignatureResult 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.VisaDataForApprove import com.tangem.domain.visa.model.VisaSignedDataByCustomerWallet @@ -175,6 +176,10 @@ interface TangemSdkManager { preflightReadFilter: PreflightReadFilter, ): Either + suspend fun tangemPayProduceVirtualAccountData( + preflightReadFilter: PreflightReadFilter, + ): Either + suspend fun getWithdrawalSignature( hash: String, preflightReadFilter: PreflightReadFilter,