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

@ -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<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(
userWallet: UserWallet,
hash: String,

View file

@ -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<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(
hotWallet: UserWallet.Hot,
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.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)
}
}
}

View file

@ -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<VirtualAccountStatusValue, BigDecimal> {
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) {

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.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<Blockchain>(), any<Network.DerivationPath>(), any<UserWallet>())
} 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<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
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<Blockchain>(), any<Network.DerivationPath>(), any<UserWallet>())
} 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()) }
}
}

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(
userWallet: UserWallet.Cold,
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(
userWalletId: UserWalletId,
seedKey: ByteArrayKey,

View file

@ -101,6 +101,11 @@ internal class DefaultHotMapDerivationsRepository @Inject constructor(
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(
userWallet: UserWallet.Hot,
networksWithDerivationPath: Map<BackendId, String?>,