From ed26d23a78164c6c185f146bea517f4e64b0a7b2 Mon Sep 17 00:00:00 2001 From: Tangem Date: Mon, 22 Jun 2026 16:14:23 +0500 Subject: [PATCH] Updated on 2026-08-14 --- .../multi/DefaultMultiNetworkStatusFetcher.kt | 2 +- .../DefaultSingleNetworkStatusFetcher.kt | 1 + .../entity/DefaultTangemPayCurrencyFactory.kt | 24 ++++- .../DefaultVirtualAccountStatusFetcher.kt | 74 +++++++++++++ .../DefaultVirtualAccountStatusFetcherTest.kt | 100 ++++++++++++++++++ .../domain/card/common/visa/VisaUtilities.kt | 1 + .../multi/MultiNetworkStatusFetcher.kt | 14 ++- .../single/SingleNetworkStatusFetcher.kt | 10 +- .../domain/pay/TangemPayCurrencyFactory.kt | 1 + 9 files changed, 219 insertions(+), 8 deletions(-) create mode 100644 data/visa/src/test/kotlin/com/tangem/data/virtualaccount/flow/DefaultVirtualAccountStatusFetcherTest.kt diff --git a/data/networks/src/main/java/com/tangem/data/networks/multi/DefaultMultiNetworkStatusFetcher.kt b/data/networks/src/main/java/com/tangem/data/networks/multi/DefaultMultiNetworkStatusFetcher.kt index 1f3e0860a0..7db5617464 100644 --- a/data/networks/src/main/java/com/tangem/data/networks/multi/DefaultMultiNetworkStatusFetcher.kt +++ b/data/networks/src/main/java/com/tangem/data/networks/multi/DefaultMultiNetworkStatusFetcher.kt @@ -67,7 +67,7 @@ internal class DefaultMultiNetworkStatusFetcher @Inject constructor( commonNetworkStatusFetcher.fetch( userWalletId = params.userWalletId, network = network, - networkCurrencies = networksCurrencies[network].orEmpty().toSet(), + networkCurrencies = networksCurrencies[network].orEmpty().toSet() + params.extraTokens, xpub = xpubByNetwork[network], ) } diff --git a/data/networks/src/main/java/com/tangem/data/networks/single/DefaultSingleNetworkStatusFetcher.kt b/data/networks/src/main/java/com/tangem/data/networks/single/DefaultSingleNetworkStatusFetcher.kt index c89ba87fe5..0ace80ea40 100644 --- a/data/networks/src/main/java/com/tangem/data/networks/single/DefaultSingleNetworkStatusFetcher.kt +++ b/data/networks/src/main/java/com/tangem/data/networks/single/DefaultSingleNetworkStatusFetcher.kt @@ -21,6 +21,7 @@ internal class DefaultSingleNetworkStatusFetcher @Inject constructor( params = MultiNetworkStatusFetcher.Params( userWalletId = params.userWalletId, networks = setOf(params.network), + extraTokens = params.extraTokens, ), ) } diff --git a/data/visa/src/main/kotlin/com/tangem/data/pay/entity/DefaultTangemPayCurrencyFactory.kt b/data/visa/src/main/kotlin/com/tangem/data/pay/entity/DefaultTangemPayCurrencyFactory.kt index 711c82bc5f..bb95aa5384 100644 --- a/data/visa/src/main/kotlin/com/tangem/data/pay/entity/DefaultTangemPayCurrencyFactory.kt +++ b/data/visa/src/main/kotlin/com/tangem/data/pay/entity/DefaultTangemPayCurrencyFactory.kt @@ -5,8 +5,9 @@ import com.tangem.data.common.currency.CryptoCurrencyFactory import com.tangem.data.common.network.NetworkFactory import com.tangem.domain.card.common.visa.VisaUtilities import com.tangem.domain.common.wallets.UserWalletsListRepository -import com.tangem.domain.common.wallets.requireUserWalletsSync +import com.tangem.domain.common.wallets.getSyncStrict import com.tangem.domain.models.currency.CryptoCurrency +import com.tangem.domain.models.network.Network import com.tangem.domain.models.wallet.UserWalletId import com.tangem.domain.pay.TangemPayCurrencyFactory import javax.inject.Inject @@ -23,9 +24,7 @@ internal class DefaultTangemPayCurrencyFactory @Inject constructor( } override fun create(userWalletId: UserWalletId): CryptoCurrency.Token { - val userWallet = userWalletsListRepository.requireUserWalletsSync() - .firstOrNull { it.walletId == userWalletId } - ?: error("User wallet with id $userWalletId not found") + val userWallet = userWalletsListRepository.getSyncStrict(userWalletId) val network = networkFactory.create( blockchain = VisaUtilities.visaBlockchain, userWallet = userWallet, @@ -40,4 +39,21 @@ internal class DefaultTangemPayCurrencyFactory @Inject constructor( decimals = TangemPayCurrencyFactory.TOKEN_DECIMALS, ) } + + override fun createVirtualAccountToken(userWalletId: UserWalletId): CryptoCurrency.Token { + val userWallet = userWalletsListRepository.getSyncStrict(userWalletId) + val network = networkFactory.create( + blockchain = VisaUtilities.visaBlockchain, + derivationPath = Network.DerivationPath.Custom(VisaUtilities.virtualAccountDerivationPath.rawPath), + userWallet = userWallet, + ) + return cryptoCurrencyFactory.createToken( + network = requireNotNull(network), + rawId = TangemPayCurrencyFactory.TOKEN_ID, + name = TangemPayCurrencyFactory.TOKEN_NAME, + symbol = TangemPayCurrencyFactory.TOKEN_NAME, + contractAddress = TangemPayCurrencyFactory.TOKEN_CONTRACT_ADDRESS, + decimals = TangemPayCurrencyFactory.TOKEN_DECIMALS, + ) + } } \ 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 8a1643d193..6c0249af86 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 @@ -1,19 +1,41 @@ package com.tangem.data.virtualaccount.flow import arrow.core.Either +import arrow.core.left +import arrow.core.right +import com.tangem.data.common.network.NetworkFactory import com.tangem.data.virtualaccount.store.VirtualAccountStatusesStore +import com.tangem.domain.card.common.visa.VisaUtilities +import com.tangem.domain.common.wallets.UserWalletsListRepository +import com.tangem.domain.common.wallets.getSyncStrict import com.tangem.domain.core.utils.catchOn import com.tangem.domain.models.StatusSource import com.tangem.domain.models.account.Account import com.tangem.domain.models.account.AccountStatus import com.tangem.domain.models.account.VirtualAccountStatusValue +import com.tangem.domain.models.network.Network +import com.tangem.domain.models.network.NetworkStatus +import com.tangem.domain.models.wallet.UserWalletId +import com.tangem.domain.networks.single.SingleNetworkStatusFetcher +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 import javax.inject.Inject +@Suppress("LongParameterList") internal class DefaultVirtualAccountStatusFetcher @Inject constructor( private val virtualAccountStatusesStore: VirtualAccountStatusesStore, private val dispatchers: CoroutineDispatcherProvider, + private val networkFactory: NetworkFactory, + private val tangemPayCurrencyFactory: TangemPayCurrencyFactory, + private val userWalletsListRepository: UserWalletsListRepository, + private val singleNetworkStatusFetcher: SingleNetworkStatusFetcher, + private val singleNetworkStatusSupplier: SingleNetworkStatusSupplier, ) : VirtualAccountStatusFetcher { override suspend fun invoke(params: VirtualAccountStatusFetcher.Params) = Either.catchOn(dispatchers.default) { @@ -21,6 +43,7 @@ internal class DefaultVirtualAccountStatusFetcher @Inject constructor( // TODO([REDACTED_TASK_KEY]): Replace with the real VA status fetch (provisioning state, balance and banking // details) from the backend once Virtual Account status endpoints are available. Until then the // account is surfaced as NotCreated so the entity flows through the app end-to-end. + getBalance(params.userWalletId) virtualAccountStatusesStore.store( userWalletId = params.userWalletId, status = AccountStatus.Virtual(account = account, value = VirtualAccountStatusValue.NotCreated), @@ -31,4 +54,55 @@ internal class DefaultVirtualAccountStatusFetcher @Inject constructor( source = StatusSource.ONLY_CACHE, ) } + + 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), + userWallet = userWallet, + ) + if (network == null) { + TangemLogger.withTag(TAG).d("Can not create network for Virtual account") + return VirtualAccountStatusValue.Error.Unavailable.left() + } + val token = tangemPayCurrencyFactory.createVirtualAccountToken(userWalletId) + + singleNetworkStatusFetcher( + SingleNetworkStatusFetcher.Params( + userWalletId = userWalletId, + network = network, + extraTokens = setOf(token), + ), + ) + + val verifiedStatus = singleNetworkStatusSupplier + .getSyncOrNull(SingleNetworkStatusProducer.Params(userWalletId, network)) + ?.value as? NetworkStatus.Verified + val balance = (verifiedStatus?.amounts?.get(token.id) as? NetworkStatus.Amount.Loaded)?.value + + return if (balance != null) { + TangemLogger.withTag(TAG).d("VA on-chain balance = $balance") + balance.right() + } else { + TangemLogger.withTag(TAG).d("Can not get VA balance") + VirtualAccountStatusValue.Error.Unavailable.left() + } + } + + private companion object { + private const val TAG = "VirtualAccountStatusFetcher" + } } \ 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 new file mode 100644 index 0000000000..2960eaa74b --- /dev/null +++ b/data/visa/src/test/kotlin/com/tangem/data/virtualaccount/flow/DefaultVirtualAccountStatusFetcherTest.kt @@ -0,0 +1,100 @@ +package com.tangem.data.virtualaccount.flow + +import arrow.core.right +import com.tangem.blockchain.common.Blockchain +import com.tangem.data.common.network.NetworkFactory +import com.tangem.data.virtualaccount.store.VirtualAccountStatusesStore +import com.tangem.domain.common.wallets.UserWalletsListRepository +import com.tangem.domain.models.currency.CryptoCurrency +import com.tangem.domain.models.network.Network +import com.tangem.domain.models.wallet.UserWallet +import com.tangem.domain.models.wallet.UserWalletId +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 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 { + + private val virtualAccountStatusesStore: VirtualAccountStatusesStore = mockk(relaxed = true) + private val dispatchers = TestingCoroutineDispatcherProvider() + private val networkFactory: NetworkFactory = mockk() + private val tangemPayCurrencyFactory: TangemPayCurrencyFactory = mockk() + private val userWalletsListRepository: UserWalletsListRepository = mockk() + private val singleNetworkStatusFetcher: SingleNetworkStatusFetcher = mockk() + private val singleNetworkStatusSupplier: SingleNetworkStatusSupplier = mockk(relaxed = true) + + private val fetcher = DefaultVirtualAccountStatusFetcher( + virtualAccountStatusesStore = virtualAccountStatusesStore, + dispatchers = dispatchers, + networkFactory = networkFactory, + tangemPayCurrencyFactory = tangemPayCurrencyFactory, + userWalletsListRepository = userWalletsListRepository, + singleNetworkStatusFetcher = singleNetworkStatusFetcher, + singleNetworkStatusSupplier = singleNetworkStatusSupplier, + ) + + private val userWalletId = UserWalletId("011") + private val userWallet: UserWallet = mockk { every { walletId } returns userWalletId } + private val network: Network = mockk() + private val token: CryptoCurrency.Token = mockk() + + @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 VA derivation missing WHEN invoke THEN on-chain fetch skipped`() = runTest { + // Arrange + every { userWallet.hasDerivation(any(), any()) } returns false + + // Act + fetcher.invoke(VirtualAccountStatusFetcher.Params(userWalletId)) + + // 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/domain/card/src/main/kotlin/com/tangem/domain/card/common/visa/VisaUtilities.kt b/domain/card/src/main/kotlin/com/tangem/domain/card/common/visa/VisaUtilities.kt index ef0f7ffdf6..6b0800e209 100644 --- a/domain/card/src/main/kotlin/com/tangem/domain/card/common/visa/VisaUtilities.kt +++ b/domain/card/src/main/kotlin/com/tangem/domain/card/common/visa/VisaUtilities.kt @@ -22,6 +22,7 @@ object VisaUtilities { val visaDefaultDerivationPath get() = visaBlockchain.derivationPath(DerivationStyle.V3) val customDerivationPath = DerivationPath("m/44'/60'/999999'/0/0") + val virtualAccountDerivationPath = DerivationPath("m/44'/60'/999998'/0/0") val curve = EllipticCurve.Secp256k1 fun signWithNonceMessage(nonce: String): String { diff --git a/domain/networks/src/main/java/com/tangem/domain/networks/multi/MultiNetworkStatusFetcher.kt b/domain/networks/src/main/java/com/tangem/domain/networks/multi/MultiNetworkStatusFetcher.kt index dcd68ceb1b..7eeee62750 100644 --- a/domain/networks/src/main/java/com/tangem/domain/networks/multi/MultiNetworkStatusFetcher.kt +++ b/domain/networks/src/main/java/com/tangem/domain/networks/multi/MultiNetworkStatusFetcher.kt @@ -1,6 +1,7 @@ package com.tangem.domain.networks.multi import com.tangem.domain.core.flow.FlowFetcher +import com.tangem.domain.models.currency.CryptoCurrency import com.tangem.domain.models.network.Network import com.tangem.domain.models.wallet.UserWalletId @@ -11,5 +12,16 @@ import com.tangem.domain.models.wallet.UserWalletId */ interface MultiNetworkStatusFetcher : FlowFetcher { - data class Params(val userWalletId: UserWalletId, val networks: Set) + /** + * Params + * + * @property userWalletId user wallet id + * @property networks networks whose statuses are fetched + * @property extraTokens additional tokens to fetch balances for, beyond the wallet's added currencies + */ + data class Params( + val userWalletId: UserWalletId, + val networks: Set, + val extraTokens: Set = emptySet(), + ) } \ No newline at end of file diff --git a/domain/networks/src/main/java/com/tangem/domain/networks/single/SingleNetworkStatusFetcher.kt b/domain/networks/src/main/java/com/tangem/domain/networks/single/SingleNetworkStatusFetcher.kt index 7c638d12f4..5923c9d977 100644 --- a/domain/networks/src/main/java/com/tangem/domain/networks/single/SingleNetworkStatusFetcher.kt +++ b/domain/networks/src/main/java/com/tangem/domain/networks/single/SingleNetworkStatusFetcher.kt @@ -1,6 +1,7 @@ package com.tangem.domain.networks.single import com.tangem.domain.core.flow.FlowFetcher +import com.tangem.domain.models.currency.CryptoCurrency import com.tangem.domain.models.network.Network import com.tangem.domain.models.wallet.UserWalletId @@ -15,7 +16,12 @@ interface SingleNetworkStatusFetcher : FlowFetcher = emptySet(), + ) } \ No newline at end of file diff --git a/domain/visa/src/main/kotlin/com/tangem/domain/pay/TangemPayCurrencyFactory.kt b/domain/visa/src/main/kotlin/com/tangem/domain/pay/TangemPayCurrencyFactory.kt index 39bdae4191..fa37a30d68 100644 --- a/domain/visa/src/main/kotlin/com/tangem/domain/pay/TangemPayCurrencyFactory.kt +++ b/domain/visa/src/main/kotlin/com/tangem/domain/pay/TangemPayCurrencyFactory.kt @@ -17,6 +17,7 @@ interface TangemPayCurrencyFactory { * @throws IllegalStateException if no wallet with [userWalletId] is currently loaded. */ fun create(userWalletId: UserWalletId): CryptoCurrency.Token + fun createVirtualAccountToken(userWalletId: UserWalletId): CryptoCurrency.Token /** Hardcoded token metadata for the Tangem Pay currency (USDC on Polygon). */ companion object {