Updated on 2026-08-14

This commit is contained in:
Tangem 2026-04-02 10:24:41 +03:00
commit af09ce9ca4
877 changed files with 16091 additions and 6613 deletions

View file

@ -108,16 +108,14 @@ data class AccountList private constructor(
}
fun flattenMapCurrencies(): Map<AccountCurrencyId, CryptoCurrency> = buildMap {
accounts.forEach { acc ->
val account = when (acc) {
is Account.CryptoPortfolio -> acc
is Account.Payment -> TODO("[REDACTED_JIRA]")
accounts
.filterIsInstance<Account.CryptoPortfolio>()
.forEach { account ->
account.cryptoCurrencies.forEach { currency ->
val key = account.accountId to currency.id
put(key, currency)
}
}
account.cryptoCurrencies.forEach { currency ->
val key = account.accountId to currency.id
put(key, currency)
}
}
}
/**

View file

@ -28,18 +28,20 @@ dependencies {
api(projects.domain.staking)
api(projects.domain.tokens)
api(projects.domain.tokens.models)
api(projects.domain.visa)
api(projects.domain.walletManager)
api(projects.domain.wallets)
implementation(projects.libs.blockchainSdk)
implementation(projects.libs.crypto)
implementation(projects.core.utils)
implementation(deps.kotlin.datetime)
implementation(deps.kotlin.serialization)
implementation(deps.timber)
implementation(deps.kermit)
implementation(tangemDeps.blockchain)
implementation(tangemDeps.card.core)
implementation(tangemDeps.hot.core)
// region DI
implementation(deps.hilt.android)

View file

@ -1,12 +1,12 @@
package com.tangem.domain.account.status.producer
import co.touchlab.kermit.Logger
import com.tangem.core.analytics.api.AnalyticsExceptionHandler
import com.tangem.core.analytics.models.ExceptionAnalyticsEvent
import com.tangem.domain.core.flow.FlowProducer
import com.tangem.domain.core.flow.FlowProducerTools
import com.tangem.utils.coroutines.AppCoroutineScope
import com.tangem.utils.coroutines.CoroutineDispatcherProvider
import com.tangem.utils.logging.TangemLogger
import kotlinx.coroutines.delay
import kotlinx.coroutines.flow.*
import javax.inject.Inject
@ -55,7 +55,7 @@ class DefaultFlowProducerTools @Inject constructor(
private fun logError(cause: Throwable, flowProducerName: String, attempt: Long) {
val tag = "FlowProducerRetryWhen"
Logger.withTag(tag)
TangemLogger.withTag(tag)
.e("flowProducerName $flowProducerName attempt $attempt", cause)
val event = ExceptionAnalyticsEvent(

View file

@ -3,6 +3,7 @@ package com.tangem.domain.account.status.producer
import arrow.core.Option
import arrow.core.none
import arrow.core.toOption
import com.tangem.common.card.FirmwareVersion
import com.tangem.core.analytics.api.AnalyticsExceptionHandler
import com.tangem.domain.account.models.AccountCurrencyId
import com.tangem.domain.account.models.AccountList
@ -33,6 +34,7 @@ import com.tangem.domain.models.wallet.isMultiCurrency
import com.tangem.domain.networks.multi.MultiNetworkStatusProducer
import com.tangem.domain.networks.multi.MultiNetworkStatusSupplier
import com.tangem.domain.networks.repository.NetworksRepository
import com.tangem.domain.pay.flow.PaymentAccountStatusSupplier
import com.tangem.domain.quotes.multi.MultiQuoteStatusSupplier
import com.tangem.domain.staking.StakingIdFactory
import com.tangem.domain.staking.multi.MultiStakingBalanceProducer
@ -42,6 +44,7 @@ import com.tangem.domain.tokens.operations.CryptoCurrencyStatusFactory
import com.tangem.domain.tokens.operations.PriceChangeCalculator
import com.tangem.domain.tokens.operations.TokenListFactory
import com.tangem.domain.tokens.operations.TotalFiatBalanceCalculator
import com.tangem.hot.sdk.model.HotWalletId
import com.tangem.utils.coroutines.CoroutineDispatcherProvider
import dagger.assisted.Assisted
import dagger.assisted.AssistedFactory
@ -69,6 +72,7 @@ import java.math.BigDecimal
*
[REDACTED_AUTHOR]
*/
// TODO: Move to :data:account:status [REDACTED_JIRA]
@Suppress("LongParameterList")
@OptIn(ExperimentalCoroutinesApi::class)
internal class DefaultSingleAccountStatusListProducer @AssistedInject constructor(
@ -76,6 +80,7 @@ internal class DefaultSingleAccountStatusListProducer @AssistedInject constructo
override val flowProducerTools: FlowProducerTools,
private val userWalletsListRepository: UserWalletsListRepository,
private val singleAccountListSupplier: SingleAccountListSupplier,
private val paymentAccountStatusSupplier: PaymentAccountStatusSupplier,
private val networksRepository: NetworksRepository,
private val dispatchers: CoroutineDispatcherProvider,
private val networkStatusSupplier: MultiNetworkStatusSupplier,
@ -116,31 +121,52 @@ internal class DefaultSingleAccountStatusListProducer @AssistedInject constructo
flattenCurrency = flattenCurrency,
)
combine(
if (userWallet.isPaymentAccountSupported()) {
combineWithPaymentAccount(
accountListFlow = accountListFlow,
cryptoCurrencyStatusFlow = cryptoCurrencyStatusFlow,
paymentAccountStatusFlow = paymentAccountStatusSupplier.invoke(userWalletId = params.userWalletId),
)
} else {
combineWithoutPaymentAccount(
accountListFlow = accountListFlow,
cryptoCurrencyStatusFlow = cryptoCurrencyStatusFlow,
)
}
.collect { accountStatusList -> channel.send(accountStatusList) }
}
private fun combineWithPaymentAccount(
accountListFlow: StateFlow<AccountList>,
cryptoCurrencyStatusFlow: Flow<Map<AccountCurrencyId, CryptoCurrencyStatus>>,
paymentAccountStatusFlow: Flow<AccountStatus.Payment>,
): Flow<AccountStatusList> {
return combine(
flow = accountListFlow,
flow2 = cryptoCurrencyStatusFlow,
transform = { accountList, currencyStatusMap ->
val accountStatuses: List<AccountStatus.CryptoPortfolio> = accountList.accounts.map { acc ->
val account: Account.CryptoPortfolio = when (acc) {
is Account.CryptoPortfolio -> acc
is Account.Payment -> TODO("[REDACTED_JIRA]")
}
if (account.cryptoCurrencies.isEmpty()) {
account.toEmptyAccountStatus()
} else {
val statuses: List<CryptoCurrencyStatus> = account.cryptoCurrencies.map { currency ->
val acId = account.accountId to currency.id
currencyStatusMap[acId] ?: currency.toLoadingCurrencyStatus()
flow3 = paymentAccountStatusFlow,
transform = { accountList, currencyStatusMap, paymentAccountStatus ->
val accountStatuses = accountList.accounts.map { account ->
when (account) {
is Account.Payment -> paymentAccountStatus
is Account.CryptoPortfolio -> if (account.cryptoCurrencies.isEmpty()) {
account.toEmptyAccountStatus()
} else {
val statuses: List<CryptoCurrencyStatus> =
account.cryptoCurrencies.map { currency ->
val acId = account.accountId to currency.id
currencyStatusMap[acId] ?: currency.toLoadingCurrencyStatus()
}
AccountStatus.CryptoPortfolio(
account = account,
tokenList = TokenListFactory.create(
statuses = statuses,
groupType = accountList.groupType,
sortType = accountList.sortType,
),
priceChangeLce = PriceChangeCalculator.calculate(statuses = statuses),
)
}
AccountStatus.CryptoPortfolio(
account = account,
tokenList = TokenListFactory.create(
statuses = statuses,
groupType = accountList.groupType,
sortType = accountList.sortType,
),
priceChangeLce = PriceChangeCalculator.calculate(statuses = statuses),
)
}
}
val balances = accountStatuses.flattenTotalFiatBalance()
@ -156,7 +182,58 @@ internal class DefaultSingleAccountStatusListProducer @AssistedInject constructo
)
},
)
.collect { accountStatusList -> channel.send(accountStatusList) }
}
private fun combineWithoutPaymentAccount(
accountListFlow: StateFlow<AccountList>,
cryptoCurrencyStatusFlow: Flow<Map<AccountCurrencyId, CryptoCurrencyStatus>>,
): Flow<AccountStatusList> {
return combine(
flow = accountListFlow,
flow2 = cryptoCurrencyStatusFlow,
transform = { accountList, currencyStatusMap ->
val accountStatuses = accountList.accounts
.filterIsInstance<Account.CryptoPortfolio>()
.map { account ->
when (account) {
is Account.CryptoPortfolio -> if (account.cryptoCurrencies.isEmpty()) {
account.toEmptyAccountStatus()
} else {
val statuses: List<CryptoCurrencyStatus> =
account.cryptoCurrencies.map { currency ->
val acId = account.accountId to currency.id
currencyStatusMap[acId] ?: currency.toLoadingCurrencyStatus()
}
AccountStatus.CryptoPortfolio(
account = account,
tokenList = TokenListFactory.create(
statuses = statuses,
groupType = accountList.groupType,
sortType = accountList.sortType,
),
priceChangeLce = PriceChangeCalculator.calculate(statuses = statuses),
)
}
}
}
val balances = accountStatuses.flattenTotalFiatBalance()
AccountStatusList(
userWalletId = accountList.userWalletId,
accountStatuses = accountStatuses,
totalAccounts = accountList.totalAccounts,
totalFiatBalance = TotalFiatBalanceCalculator.calculate(balances),
totalArchivedAccounts = accountList.totalArchivedAccounts,
sortType = accountList.sortType,
groupType = accountList.groupType,
)
},
)
}
private fun UserWallet.isPaymentAccountSupported(): Boolean = when (this) {
is UserWallet.Cold -> scanResponse.card.firmwareVersion >= FirmwareVersion.HDWalletAvailable
is UserWallet.Hot -> hotWalletId.authType != HotWalletId.AuthType.NoPassword
}
private fun ProducerScope<AccountStatusList>.flattenCurrencyStatusFlow(
@ -278,7 +355,7 @@ internal class DefaultSingleAccountStatusListProducer @AssistedInject constructo
return map { accountStatus ->
when (accountStatus) {
is AccountStatus.CryptoPortfolio -> accountStatus.tokenList.totalFiatBalance
is AccountStatus.Payment -> accountStatus.totalFiatBalance
is AccountStatus.Payment -> accountStatus.value.totalFiatBalance
}
}
}

View file

@ -14,8 +14,8 @@ import com.tangem.domain.models.currency.CryptoCurrency
import com.tangem.domain.models.wallet.UserWalletId
import com.tangem.domain.tokens.error.TokenListSortingError
import com.tangem.utils.coroutines.CoroutineDispatcherProvider
import com.tangem.utils.logging.TangemLogger
import kotlinx.coroutines.coroutineScope
import timber.log.Timber
private typealias SortingErrorByAccountId = MutableMap<AccountId, TokenListSortingError>
@ -68,7 +68,7 @@ class ApplyTokenListSortingUseCase(
maybeSortedAccountList.toEitherNeg()
.onLeft { errorByAccountId ->
Timber.e(
TangemLogger.e(
"""
Unable to sort tokens for accounts: ${
errorByAccountId.entries.joinToString { "${it.key.value}: ${it.value}" }

View file

@ -20,7 +20,7 @@ import com.tangem.domain.models.wallet.UserWalletId
import com.tangem.domain.models.wallet.isMultiCurrency
import com.tangem.domain.networks.multi.MultiNetworkStatusProducer
import com.tangem.domain.networks.multi.MultiNetworkStatusSupplier
import timber.log.Timber
import com.tangem.utils.logging.TangemLogger
import kotlin.contracts.ExperimentalContracts
import kotlin.contracts.contract
@ -130,7 +130,7 @@ class GetAccountCurrencyByAddressUseCase(
}
return value ?: run {
Timber.d(message())
TangemLogger.d(message())
raise(None)
}
}

View file

@ -8,7 +8,6 @@ import com.tangem.domain.account.status.producer.SingleAccountStatusListProducer
import com.tangem.domain.account.status.supplier.SingleAccountStatusListSupplier
import com.tangem.domain.account.status.utils.CryptoCurrencyBalanceFetcher
import com.tangem.domain.account.status.utils.CryptoCurrencyMetadataCleaner
import com.tangem.utils.coroutines.AppCoroutineScope
import com.tangem.domain.core.utils.eitherOn
import com.tangem.domain.express.ExpressServiceFetcher
import com.tangem.domain.express.models.ExpressAsset
@ -22,10 +21,14 @@ import com.tangem.domain.models.wallet.UserWalletId
import com.tangem.domain.tokens.repository.CurrenciesRepository
import com.tangem.domain.walletmanager.WalletManagersFacade
import com.tangem.domain.wallets.derivations.DerivationsRepository
import com.tangem.utils.coroutines.AppCoroutineScope
import com.tangem.utils.coroutines.CoroutineDispatcherProvider
import com.tangem.utils.coroutines.runSuspendCatching
import kotlinx.coroutines.*
import timber.log.Timber
import com.tangem.utils.logging.TangemLogger
import kotlinx.coroutines.NonCancellable
import kotlinx.coroutines.coroutineScope
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
/**
* Use case for saving crypto currencies to a specific account.
@ -75,9 +78,36 @@ class ManageCryptoCurrenciesUseCase(
add: List<CryptoCurrency> = emptyList(),
remove: List<CryptoCurrency> = emptyList(),
skipDerivationErrors: Boolean = true,
): Either<Throwable, Unit> = invokeInternal(
accountId = accountId,
add = add,
remove = remove,
skipDerivationErrors = skipDerivationErrors,
awaitTokensSyncFinished = false,
)
suspend fun invokeAndAwait(
accountId: AccountId,
add: List<CryptoCurrency> = emptyList(),
remove: List<CryptoCurrency> = emptyList(),
skipDerivationErrors: Boolean = true,
): Either<Throwable, Unit> = invokeInternal(
accountId = accountId,
add = add,
remove = remove,
skipDerivationErrors = skipDerivationErrors,
awaitTokensSyncFinished = true,
)
private suspend fun invokeInternal(
accountId: AccountId,
add: List<CryptoCurrency>,
remove: List<CryptoCurrency>,
skipDerivationErrors: Boolean,
awaitTokensSyncFinished: Boolean,
): Either<Throwable, Unit> = eitherOn(dispatchers.default) {
if (add.isEmpty() && remove.isEmpty()) {
Timber.d("No currencies to add or remove, skipping")
TangemLogger.d("No currencies to add or remove, skipping")
return@eitherOn
}
@ -89,7 +119,7 @@ class ManageCryptoCurrenciesUseCase(
.modify(add = add, remove = remove)
if (!modifiedCurrencyList.hasChanges) {
Timber.d("No changes in currencies, skipping")
TangemLogger.d("No changes in currencies, skipping")
return@withContext
}
@ -108,9 +138,11 @@ class ManageCryptoCurrenciesUseCase(
account = accountStatus.account.copy(cryptoCurrencies = modifiedCurrencyList.total),
)
parallelUpdatingScope.launch {
syncTokens(userWalletId, modifiedCurrencyList)
syncTokensAndLaunchUpdates(
userWalletId = userWalletId,
modifiedCurrencyList = modifiedCurrencyList,
awaitSync = awaitTokensSyncFinished,
) {
cryptoCurrencyBalanceFetcher(userWalletId = userWalletId, currencies = modifiedCurrencyList.added)
refreshExpress(userWalletId = userWalletId, currencies = modifiedCurrencyList.total)
clearMetadata(userWalletId = userWalletId, currencies = modifiedCurrencyList.removed)
@ -126,6 +158,7 @@ class ManageCryptoCurrenciesUseCase(
accountId: AccountId,
networkId: String,
contractAddress: String,
awaitTokensSyncFinished: Boolean = false,
): Either<Throwable, CryptoCurrency> = eitherOn(dispatchers.default) {
val userWalletId = accountId.userWalletId
@ -149,9 +182,11 @@ class ManageCryptoCurrenciesUseCase(
saveAccount(account = accountStatus.account.copy(cryptoCurrencies = modifiedCurrencyList.total))
parallelUpdatingScope.launch {
syncTokens(userWalletId, modifiedCurrencyList)
syncTokensAndLaunchUpdates(
userWalletId = userWalletId,
modifiedCurrencyList = modifiedCurrencyList,
awaitSync = awaitTokensSyncFinished,
) {
cryptoCurrencyBalanceFetcher(userWalletId = userWalletId, currencies = listOf(tokenToAdd))
refreshExpress(userWalletId = userWalletId, currencies = modifiedCurrencyList.total)
}
@ -279,7 +314,26 @@ class ManageCryptoCurrenciesUseCase(
createWalletManagers(userWalletId = userWalletId, currencies = modifiedCurrencyList.added)
runSuspendCatching { accountsCRUDRepository.syncTokens(userWalletId) }
.onFailure { Timber.e(it, "Failed to sync tokens for wallet $userWalletId") }
.onFailure { TangemLogger.e("Failed to sync tokens for wallet $userWalletId", it) }
}
private suspend fun syncTokensAndLaunchUpdates(
userWalletId: UserWalletId,
modifiedCurrencyList: ModifiedCurrencyList,
awaitSync: Boolean,
updates: suspend () -> Unit,
) {
if (awaitSync) {
syncTokens(userWalletId, modifiedCurrencyList)
parallelUpdatingScope.launch {
updates()
}
} else {
parallelUpdatingScope.launch {
syncTokens(userWalletId, modifiedCurrencyList)
updates()
}
}
}
/**
@ -296,7 +350,7 @@ class ManageCryptoCurrenciesUseCase(
runSuspendCatching {
walletManagersFacade.getOrCreateWalletManager(userWalletId = userWalletId, network = network)
}
.onFailure { Timber.e(it, "Failed to create wallet manager for network ${network.id}") }
.onFailure { TangemLogger.e("Failed to create wallet manager for network ${network.id}", it) }
}
}

View file

@ -5,11 +5,11 @@ import com.tangem.domain.models.wallet.UserWalletId
import com.tangem.domain.tokens.BalanceFetchingOperations
import com.tangem.domain.tokens.FetchErrorFormatter
import com.tangem.domain.tokens.FetchingSource
import com.tangem.utils.logging.TangemLogger
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.launch
import kotlinx.coroutines.sync.Mutex
import kotlinx.coroutines.sync.withLock
import timber.log.Timber
import java.util.concurrent.ConcurrentHashMap
/**
@ -87,7 +87,7 @@ class CryptoCurrencyBalanceFetcher(
)
if (errors.isNotEmpty()) {
Timber.e(FetchErrorFormatter.format(userWalletId, errors))
TangemLogger.e(FetchErrorFormatter.format(userWalletId, errors))
}
}

View file

@ -11,7 +11,7 @@ import com.tangem.domain.account.status.utils.AccountCryptoCurrencyStatusFinder.
import com.tangem.domain.models.account.Account
import com.tangem.domain.models.currency.CryptoCurrency
import com.tangem.domain.models.network.Network
import timber.log.Timber
import com.tangem.utils.logging.TangemLogger
/**
* Extension functions for retrieving [CryptoCurrency] from an [AccountList] or [Account.CryptoPortfolio].
@ -57,7 +57,7 @@ object CryptoCurrencyOperations {
val currencyId = catch(
block = { CryptoCurrency.ID.fromValue(currencyIdValue) },
catch = { throwable ->
Timber.e("Error on converting currencyId: $throwable")
TangemLogger.e("Error on converting currencyId: $throwable")
raise(None)
},
)

View file

@ -23,8 +23,8 @@ dependencies {
implementation(projects.domain.tokens.models)
implementation(projects.domain.wallets.models)
implementation(projects.domain.visa.models)
implementation(projects.core.utils)
implementation(deps.timber)
implementation(tangemDeps.card.core)
implementation(tangemDeps.blockchain) {

View file

@ -0,0 +1,10 @@
package com.tangem.domain.card
import com.tangem.core.analytics.models.AnalyticsParam
interface ScanFailsCounter {
fun reset()
fun onScanFailure(isUserCancelled: Boolean, source: AnalyticsParam.ScreensSources)
}

View file

@ -0,0 +1,12 @@
package com.tangem.domain.card
import com.tangem.core.analytics.models.AnalyticsParam
interface ScanFailsRequester {
suspend fun show(source: AnalyticsParam.ScreensSources): Result
sealed class Result {
data object Dismissed : Result()
}
}

View file

@ -2,7 +2,7 @@ package com.tangem.domain.card.configs
import com.tangem.blockchain.common.Blockchain
import com.tangem.common.card.EllipticCurve
import timber.log.Timber
import com.tangem.utils.logging.TangemLogger
object EdSingleCurrencyCardConfig : CardConfig {
@ -14,7 +14,7 @@ object EdSingleCurrencyCardConfig : CardConfig {
EllipticCurve.Ed25519
}
else -> {
Timber.e("Unsupported blockchain, curve not found")
TangemLogger.e("Unsupported blockchain, curve not found")
null
}
}

View file

@ -2,7 +2,7 @@ package com.tangem.domain.card.configs
import com.tangem.blockchain.common.Blockchain
import com.tangem.common.card.EllipticCurve
import timber.log.Timber
import com.tangem.utils.logging.TangemLogger
class GenericCardConfig(maxWalletCount: Int) : CardConfig {
@ -25,7 +25,7 @@ class GenericCardConfig(maxWalletCount: Int) : CardConfig {
EllipticCurve.Ed25519
}
else -> {
Timber.e("Unsupported blockchain, curve not found")
TangemLogger.e("Unsupported blockchain, curve not found")
null
}
}

View file

@ -2,7 +2,7 @@ package com.tangem.domain.card.configs
import com.tangem.blockchain.common.Blockchain
import com.tangem.common.card.EllipticCurve
import timber.log.Timber
import com.tangem.utils.logging.TangemLogger
object MultiWalletCardConfig : CardConfig {
override val mandatoryCurves: List<EllipticCurve>
@ -27,7 +27,7 @@ object MultiWalletCardConfig : CardConfig {
EllipticCurve.Bls12381G2Aug
}
else -> {
Timber.e("Unsupported blockchain, curve not found")
TangemLogger.e("Unsupported blockchain, curve not found")
null
}
}

View file

@ -2,7 +2,7 @@ package com.tangem.domain.card.configs
import com.tangem.blockchain.common.Blockchain
import com.tangem.common.card.EllipticCurve
import timber.log.Timber
import com.tangem.utils.logging.TangemLogger
data object Wallet2CardConfig : CardConfig {
override val mandatoryCurves: List<EllipticCurve>
@ -37,14 +37,14 @@ data object Wallet2CardConfig : CardConfig {
// EllipticCurve.Ed25519
// }
// else -> {
// Timber.e("Unsupported blockchain, curve not found")
// TangemLogger.e("Unsupported blockchain, curve not found")
// null
// }
// }
val curve = getPrimaryCurveForBlockchain(blockchain)
// check curve supports
if (!blockchain.getSupportedCurves().contains(curve)) {
Timber.e("Unsupported curve $curve for blockchain $blockchain")
TangemLogger.e("Unsupported curve $curve for blockchain $blockchain")
return null
}
return curve

77
domain/core/CLAUDE.md Normal file
View file

@ -0,0 +1,77 @@
# domain/core
Cross-cutting domain utilities for async data loading, error handling, and reactive streams. Not business logic — foundational abstractions used across all domain modules.
## LCE (Loading-Content-Error) Pattern
`Lce<E, C>` — sealed class representing async operation state:
- `Loading(partialContent?)` — in progress, may carry partial data
- `Content(content)` — success
- `Error(error)` — failure with typed error
Key APIs:
- `lce { }` builder — executes block in `LceRaise` context with Arrow's Raise DSL for typed error handling
- `lceFlow { }` builder — creates `LceFlow<E, C>` (alias for `Flow<Lce<E, C>>`) via channel-based producer DSL
- `LceRaise.bind()` — extracts content from Lce/Either or short-circuits on error
- Extensions: `fold()`, `map()`, `mapError()`, `toLce()`, `toEither()`
## Flow Packaging
A pattern for complex data streams where work on a single flow is split into three logically separate components: **Producer** (creation), **Supplier** (delivery/caching), and **Fetcher** (refresh). Use it only when you need flexibility in creating, reusing, fetching, and updating a data stream (e.g., network status). Do NOT use for simple cases like reading preferences.
### FlowProducer
`FlowProducer<Data>` — creates the data flow. Implement:
- `fallback: Data` — emitted when the flow throws an exception
- `produce(): Flow<Data>` — the actual flow creation logic
Built-in `produceWithFallback()` catches errors, emits `fallback`, waits 2s, then retries — keeping the flow alive for subscribers.
`FlowProducer.Factory<Params, Producer>` — creates a Producer from params. Typically implemented via Hilt `@AssistedFactory`.
**Implementation pattern:**
1. Define interface extending `FlowProducer<Data>` with inner `Params` data class and `Factory` interface
2. Create `Default*Producer` with `@AssistedInject` constructor taking `@Assisted params` + dependencies
3. Override `fallback` and `produce()`
4. Declare inner `@AssistedFactory` interface extending the Producer's Factory
### FlowSupplier / FlowCachingSupplier
`FlowSupplier<Params, Data>` — delivers a flow by params via `operator fun invoke(params): Flow<Data>`. Also provides `getSyncOrNull(params, timeout)` for one-shot access.
`FlowCachingSupplier<Producer, Params, Data>` — abstract implementation that caches flows by key. Implement:
- `factory: FlowProducer.Factory` — to create producers
- `keyCreator: (Params) -> String` — to generate cache keys
Behavior: returns cached flow if exists, otherwise creates via `factory.create(params).produceWithFallback()`, caches it, and auto-evicts on terminal exception.
**Implementation pattern:**
1. Define abstract class extending `FlowCachingSupplier` with `factory` and `keyCreator` in constructor
2. In DI module, create anonymous subclass providing the factory (injected) and keyCreator lambda
### FlowFetcher
`FlowFetcher<Params>` — triggers data refresh, returns `Either<Throwable, Unit>`. Typically updates a store/data source, causing the Producer's flow to re-emit.
**Implementation pattern:**
1. Define interface extending `FlowFetcher<Params>` with inner `Params` data class
2. Create `Default*Fetcher` with `@Inject` constructor, override `invoke` wrapping logic in `Either.catch { }`, handle errors with `.onLeft { }`
### Testing
- **FlowProducer**: test flow creation logic, params usage, emission behavior, exception handling
- **FlowFetcher**: test successful update path and error path (exception thrown)
## Chain Processing
- `Chain<E, R>` / `ResultChain<E, R>` — single operation in a chain, works with `Either<E, R>`
- `ChainProcessor<E, R>` — folds chains sequentially, stops on first error
## Error Types
- `DataError` — sealed domain error hierarchy: `NetworkError.NoInternetConnection`, `UserWalletError.WrongUserWallet`
## Either Extensions
- `Either.catchOn(dispatcher, block)` — executes on dispatcher, catches exceptions
- `eitherOn(dispatcher, block)` — Raise DSL block on specified dispatcher

View file

@ -0,0 +1,20 @@
plugins {
alias(deps.plugins.android.library)
alias(deps.plugins.kotlin.android)
id("configuration")
}
android {
namespace = "com.tangem.domain.dynamicaddresses"
}
dependencies {
api(projects.domain.core)
api(projects.domain.dynamicAddresses.models)
implementation(projects.domain.models)
implementation(tangemDeps.blockchain) {
exclude(module = "joda-time")
}
}

View file

@ -0,0 +1,7 @@
plugins {
alias(deps.plugins.kotlin.jvm)
id("configuration")
}
dependencies {
}

View file

@ -0,0 +1,9 @@
package com.tangem.domain.dynamicaddresses.model
import java.math.BigDecimal
data class ConsolidationInfo(
val fee: BigDecimal,
val inputCount: Int,
val canCoverFee: Boolean,
)

View file

@ -0,0 +1,18 @@
package com.tangem.domain.dynamicaddresses.model
import java.math.BigDecimal
enum class DynamicAddressesStatus {
ENABLED,
DISABLED,
/** Enabled on backend, but local XPUB setup required (cross-device sync) */
ENABLED_REQUIRES_SETUP,
}
data class UsedAddress(
val address: String,
val path: String,
val balance: BigDecimal,
)

View file

@ -0,0 +1,33 @@
package com.tangem.domain.dynamicaddresses
import arrow.core.Either
import arrow.core.getOrElse
import arrow.core.raise.either
import com.tangem.domain.models.network.Network
import com.tangem.domain.models.wallet.UserWalletId
import com.tangem.domain.dynamicaddresses.model.ConsolidationInfo
import com.tangem.domain.dynamicaddresses.repository.ConsolidationRepository
import com.tangem.domain.dynamicaddresses.repository.DynamicAddressesRepository
class DisableDynamicAddressesUseCase(
private val dynamicAddressesRepository: DynamicAddressesRepository,
private val consolidationRepository: ConsolidationRepository,
) {
/**
* Returns [ConsolidationInfo] when consolidation is required before disabling,
* or null when DA can be disabled immediately (no non-base balances).
*/
suspend operator fun invoke(userWalletId: UserWalletId, network: Network): Either<Throwable, ConsolidationInfo?> =
either {
val hasNonBaseBalances = dynamicAddressesRepository.hasNonBaseBalances(userWalletId, network)
if (!hasNonBaseBalances) {
dynamicAddressesRepository.disable(userWalletId, network)
return@either null
}
consolidationRepository.getConsolidationInfo(userWalletId, network)
.getOrElse { raise(it) }
}
}

View file

@ -0,0 +1,6 @@
package com.tangem.domain.dynamicaddresses
interface DynamicAddressesFeatureToggles {
val isDynamicAddressesEnabled: Boolean
}

View file

@ -0,0 +1,16 @@
package com.tangem.domain.dynamicaddresses
import arrow.core.Either
import com.tangem.domain.dynamicaddresses.repository.DynamicAddressesRepository
import com.tangem.domain.models.network.Network
import com.tangem.domain.models.wallet.UserWalletId
class EnableDynamicAddressesUseCase(
private val dynamicAddressesRepository: DynamicAddressesRepository,
) {
suspend operator fun invoke(userWalletId: UserWalletId, network: Network, xpub: String): Either<Throwable, Unit> =
Either.catch {
dynamicAddressesRepository.enable(userWalletId, network, xpub)
}
}

View file

@ -0,0 +1,16 @@
package com.tangem.domain.dynamicaddresses
import arrow.core.Either
import com.tangem.domain.dynamicaddresses.model.ConsolidationInfo
import com.tangem.domain.dynamicaddresses.repository.ConsolidationRepository
import com.tangem.domain.models.network.Network
import com.tangem.domain.models.wallet.UserWalletId
class GetConsolidationInfoUseCase(
private val consolidationRepository: ConsolidationRepository,
) {
suspend operator fun invoke(userWalletId: UserWalletId, network: Network): Either<Throwable, ConsolidationInfo> {
return consolidationRepository.getConsolidationInfo(userWalletId, network)
}
}

View file

@ -0,0 +1,16 @@
package com.tangem.domain.dynamicaddresses
import com.tangem.domain.dynamicaddresses.model.DynamicAddressesStatus
import com.tangem.domain.dynamicaddresses.repository.DynamicAddressesRepository
import com.tangem.domain.models.network.Network
import com.tangem.domain.models.wallet.UserWalletId
import kotlinx.coroutines.flow.Flow
class GetDynamicAddressesStatusUseCase(
private val dynamicAddressesRepository: DynamicAddressesRepository,
) {
operator fun invoke(userWalletId: UserWalletId, network: Network): Flow<DynamicAddressesStatus> {
return dynamicAddressesRepository.getStatus(userWalletId, network)
}
}

View file

@ -0,0 +1,16 @@
package com.tangem.domain.dynamicaddresses
import arrow.core.Either
import com.tangem.domain.dynamicaddresses.repository.DynamicAddressesRepository
import com.tangem.domain.models.network.Network
import com.tangem.domain.models.wallet.UserWalletId
class GetDynamicReceiveAddressUseCase(
private val dynamicAddressesRepository: DynamicAddressesRepository,
) {
suspend operator fun invoke(userWalletId: UserWalletId, network: Network): Either<Throwable, String> =
Either.catch {
dynamicAddressesRepository.getReceiveAddress(userWalletId, network)
}
}

View file

@ -0,0 +1,21 @@
package com.tangem.domain.dynamicaddresses.repository
import arrow.core.Either
import com.tangem.blockchain.common.TransactionSigner
import com.tangem.domain.dynamicaddresses.model.ConsolidationInfo
import com.tangem.domain.models.network.Network
import com.tangem.domain.models.wallet.UserWalletId
interface ConsolidationRepository {
suspend fun getConsolidationInfo(
userWalletId: UserWalletId,
network: Network,
): Either<Throwable, ConsolidationInfo>
suspend fun sendConsolidationTransaction(
userWalletId: UserWalletId,
network: Network,
signer: TransactionSigner,
): Either<Throwable, String>
}

View file

@ -0,0 +1,22 @@
package com.tangem.domain.dynamicaddresses.repository
import com.tangem.domain.dynamicaddresses.model.DynamicAddressesStatus
import com.tangem.domain.models.network.Network
import com.tangem.domain.models.wallet.UserWalletId
import kotlinx.coroutines.flow.Flow
interface DynamicAddressesRepository {
fun getStatus(userWalletId: UserWalletId, network: Network): Flow<DynamicAddressesStatus>
suspend fun enable(userWalletId: UserWalletId, network: Network, xpub: String)
suspend fun disable(userWalletId: UserWalletId, network: Network)
suspend fun getReceiveAddress(userWalletId: UserWalletId, network: Network): String
// for explorer url
suspend fun getLastUsedReceiveAddress(userWalletId: UserWalletId, network: Network): String?
suspend fun hasNonBaseBalances(userWalletId: UserWalletId, network: Network): Boolean
}

View file

@ -43,7 +43,6 @@ dependencies {
implementation(deps.moshi)
implementation(deps.moshi.kotlin)
implementation(deps.reKotlin)
implementation(deps.timber)
ksp(deps.moshi.kotlin.codegen)
/** Testing libraries */

View file

@ -1,27 +0,0 @@
package com.tangem.domain.features.addCustomToken
import com.tangem.blockchain.common.Blockchain
import com.tangem.blockchain.common.Token
import com.tangem.crypto.hdWallet.DerivationPath
/**
[REDACTED_AUTHOR]
*/
sealed class CustomCurrency(
val network: Blockchain,
val derivationPath: DerivationPath?,
) {
@Deprecated("It will be removed in next releases")
class CustomBlockchain(
network: Blockchain,
derivationPath: DerivationPath?,
) : CustomCurrency(network, derivationPath)
@Deprecated("It will be removed in next releases")
class CustomToken(
val token: Token,
network: Blockchain,
derivationPath: DerivationPath?,
) : CustomCurrency(network, derivationPath)
}

View file

@ -1,7 +0,0 @@
package com.tangem.domain.redux
import org.rekotlin.Action
sealed class OnboardingManageTokensAction : Action {
data object CurrenciesSaved : OnboardingManageTokensAction()
}

View file

@ -10,6 +10,4 @@ interface ReduxStateHolder {
suspend fun dispatchWithMain(action: Action)
suspend fun onUserWalletSelected(userWallet: UserWallet)
fun dispatchDialogShow(dialog: StateDialog)
}

View file

@ -1,12 +0,0 @@
package com.tangem.domain.redux
interface StateDialog {
data object NfcFeatureIsUnavailable : StateDialog
data class ScanFailsDialog(val source: ScanFailsSource, val onTryAgain: (() -> Unit)? = null) : StateDialog
enum class ScanFailsSource {
MAIN, SIGN_IN, SETTINGS, INTRO
}
}

View file

@ -49,6 +49,7 @@ data class TokenMarketInfo(
data class Metrics(
val marketRating: Int?,
val marketRatingChange24h: Int?,
val circulatingSupply: BigDecimal?,
val marketCap: BigDecimal?,
val volume24h: BigDecimal?,

View file

@ -178,12 +178,15 @@ sealed interface Account {
@Serializable
data class Payment(
override val accountId: AccountId,
override val accountName: AccountName,
val cryptoCurrencies: List<CryptoCurrency>,
) : Account {
override val accountName: AccountName.Custom = AccountName.Custom("Payment").getOrElse {
error("Can not create account name for Payment account with userWalletId = ${accountId.userWalletId}")
}
init {
error("Not yet implemented")
companion object {
operator fun invoke(userWalletId: UserWalletId): Payment {
return Payment(accountId = AccountId.forPaymentAccount(userWalletId = userWalletId))
}
}
}
}

View file

@ -1,7 +1,6 @@
package com.tangem.domain.models.account
import com.tangem.domain.core.lce.Lce
import com.tangem.domain.models.TotalFiatBalance
import com.tangem.domain.models.currency.CryptoCurrencyStatus
import com.tangem.domain.models.quote.PriceChange
import com.tangem.domain.models.tokenlist.TokenList
@ -43,7 +42,7 @@ sealed interface AccountStatus {
@Serializable
data class Payment(
override val account: Account.Payment,
val totalFiatBalance: TotalFiatBalance,
val value: PaymentAccountStatusValue,
) : AccountStatus
}

View file

@ -0,0 +1,193 @@
package com.tangem.domain.models.account
import com.tangem.domain.models.StatusSource
import com.tangem.domain.models.TotalFiatBalance
import com.tangem.domain.models.kyc.KycStatus
import com.tangem.domain.models.serialization.SerializedBigDecimal
import kotlinx.serialization.Serializable
/**
* Represents the various states a payment account can have, encapsulating different information based on the state.
*
* @property source The source of the status information.
*/
@Serializable
sealed class PaymentAccountStatusValue {
abstract val source: StatusSource
/** The total fiat balance associated with this status. */
val totalFiatBalance: TotalFiatBalance
get() = when (this) {
is Error,
is IssuingCard,
is NotCreated,
is UnderReview,
-> TotalFiatBalance.Loaded(amount = SerializedBigDecimal.ZERO, source = source)
is Loading -> TotalFiatBalance.Loading
is Locked -> TotalFiatBalance.Loaded(amount = fiatBalance.availableBalance, source = source)
is Loaded -> TotalFiatBalance.Loaded(amount = fiatBalance.availableBalance, source = source)
}
/**
* Copies the status with a new [source].
*
* @param source The new source of the status information.
*/
fun copySealed(source: StatusSource): PaymentAccountStatusValue {
return when (this) {
is IssuingCard -> copy(source = source)
is Loaded -> copy(source = source)
is Locked -> copy(source = source)
is UnderReview -> copy(source = source)
is Loading,
is NotCreated,
is Error,
-> this
}
}
/** Represents the Loading state of a payment account, typically while fetching its details. */
@Serializable
data object Loading : PaymentAccountStatusValue() {
override val source: StatusSource = StatusSource.ACTUAL
}
/** Represents a state where the payment account has not been created yet. */
@Serializable
data object NotCreated : PaymentAccountStatusValue() {
override val source: StatusSource = StatusSource.ACTUAL
}
/**
* Represents a state where the payment account is under review (KYC).
*
* @property source The source of the status information.
* @property kycStatus The current KYC status.
* @property customerId The unique identifier of the customer.
*/
@Serializable
data class UnderReview(
override val source: StatusSource,
val kycStatus: KycStatus,
val customerId: String,
) : PaymentAccountStatusValue()
/**
* Represents a state where the card for the payment account is being issued.
*
* @property source The source of the status information.
*/
@Serializable
data class IssuingCard(override val source: StatusSource) : PaymentAccountStatusValue()
/**
* Represents a state where the payment account is locked.
*
* @property source The source of the status information.
* @property customerId The unique identifier of the customer.
* @property cardId The unique identifier of the card.
* @property lastFourDigits The last four digits of the card number.
* @property currencyCode The code of the currency.
* @property depositAddress The address for deposits, if available.
* @property isPinSet Indicates if the PIN is set for the card.
* @property fiatBalance The fiat balance details.
* @property cryptoBalance The crypto balance details.
*/
@Serializable
data class Locked(
override val source: StatusSource,
val customerId: String,
val cardId: String,
val lastFourDigits: String,
val currencyCode: String,
val depositAddress: String?,
val isPinSet: Boolean,
val fiatBalance: FiatBalance,
val cryptoBalance: CryptoBalance,
) : PaymentAccountStatusValue()
/**
* Represents a state where the payment account is successfully loaded with complete information.
*
* @property source The source of the status information.
* @property customerId The unique identifier of the customer.
* @property cardId The unique identifier of the card.
* @property lastFourDigits The last four digits of the card number.
* @property currencyCode The code of the currency.
* @property depositAddress The address for deposits, if available.
* @property isPinSet Indicates if the PIN is set for the card.
* @property fiatBalance The fiat balance details.
* @property cryptoBalance The crypto balance details.
*/
@Serializable
data class Loaded(
override val source: StatusSource,
val customerId: String,
val cardId: String,
val lastFourDigits: String,
val currencyCode: String,
val depositAddress: String?,
val isPinSet: Boolean,
val fiatBalance: FiatBalance,
val cryptoBalance: CryptoBalance,
) : PaymentAccountStatusValue()
/** Represents an error state for the payment account status. */
@Serializable
sealed class Error : PaymentAccountStatusValue() {
/** Error state indicating the device is exposed. */
@Serializable
data object ExposedDevice : Error() {
override val source: StatusSource = StatusSource.ACTUAL
}
/** Error state indicating the account is unavailable. */
@Serializable
data object Unavailable : Error() {
override val source: StatusSource = StatusSource.ACTUAL
}
/** Error state indicating the account data is not synced. */
@Serializable
data object NotSynced : Error() {
override val source: StatusSource = StatusSource.ACTUAL
}
/**
* Error state indicating that card issuance failed.
*
* @property customerId The unique identifier of the customer.
*/
@Serializable
data class CardIssueFailed(val customerId: String) : Error() {
override val source: StatusSource = StatusSource.ACTUAL
}
}
/**
* Represents the fiat balance of the payment account.
*
* @property availableBalance The amount of available balance in fiat.
* @property currency The currency of the balance.
*/
@Serializable
data class FiatBalance(val availableBalance: SerializedBigDecimal, val currency: String)
/**
* Represents the crypto balance of the payment account.
*
* @property id The unique identifier of the crypto asset.
* @property chainId The identifier of the blockchain network.
* @property depositAddress The address for deposits.
* @property tokenContractAddress The contract address of the token.
* @property balance The amount of the crypto balance.
*/
@Serializable
data class CryptoBalance(
val id: String,
val chainId: Long,
val depositAddress: String,
val tokenContractAddress: String,
val balance: SerializedBigDecimal,
)
}

View file

@ -40,11 +40,13 @@ data class QuoteStatus(val rawCurrencyId: CryptoCurrency.RawID, val value: Value
*
* @property source status source
* @property fiatRate the current fiat exchange rate for the cryptocurrency
* @property fiatRateUSD the current fiat exchange rate in USD for the cryptocurrency
* @property priceChange the price change for the cryptocurrency
*/
data class Data(
override val source: StatusSource,
val fiatRate: BigDecimal,
val fiatRateUSD: BigDecimal,
val priceChange: BigDecimal,
) : Value
}

1
domain/payment/.gitignore vendored Normal file
View file

@ -0,0 +1 @@
/build

View file

@ -0,0 +1,10 @@
plugins {
alias(deps.plugins.kotlin.jvm)
id("configuration")
}
dependencies {
/** Project - Domain */
api(projects.domain.models)
implementation(projects.domain.payment.models)
}

1
domain/payment/models/.gitignore vendored Normal file
View file

@ -0,0 +1 @@
/build

View file

@ -0,0 +1,24 @@
plugins {
alias(deps.plugins.kotlin.jvm)
alias(deps.plugins.kotlin.serialization)
alias(deps.plugins.ksp)
id("configuration")
}
dependencies {
/** Project - Core */
implementation(projects.core.error)
/** Domain models */
implementation(projects.domain.models)
/** Libs - Tangem */
implementation(tangemDeps.card.core)
/** Libs - Other */
implementation(deps.moshi.adapters)
implementation(deps.kotlin.serialization)
implementation(deps.jodatime)
implementation(deps.moshi.kotlin)
ksp(deps.moshi.kotlin.codegen)
}

View file

@ -0,0 +1,24 @@
package com.tangem.domain.quotes
import com.tangem.domain.models.currency.CryptoCurrency
import com.tangem.domain.models.quote.QuoteStatus
import java.math.BigDecimal
/**
* Get currency USD quote use case
*/
class GetCurrencyUSDQuoteUseCase(
private val quotesRepository: QuotesRepository,
) {
/** Get quote by [currencyId] synchronously or null */
suspend operator fun invoke(currencyId: CryptoCurrency.RawID): BigDecimal? {
val value = quotesRepository.getCurrencyUSDQuote(currencyId)?.value
return if (value is QuoteStatus.Data) {
value.fiatRateUSD
} else {
null
}
}
}

View file

@ -12,4 +12,7 @@ interface QuotesRepository {
/** Get quotes by [currenciesIds] synchronously or null */
suspend fun getMultiQuoteSyncOrNull(currenciesIds: Set<CryptoCurrency.RawID>): Set<QuoteStatus>?
/** Get quote by [currencyId] synchronously or null */
suspend fun getCurrencyUSDQuote(currencyId: CryptoCurrency.RawID): QuoteStatus?
}

1
domain/search/.gitignore vendored Normal file
View file

@ -0,0 +1 @@
/build

View file

@ -0,0 +1,20 @@
plugins {
alias(deps.plugins.android.library)
alias(deps.plugins.kotlin.android)
id("configuration")
}
android {
namespace = "com.tangem.domain.search"
}
dependencies {
api(projects.domain.core)
api(projects.domain.models)
implementation(projects.domain.common)
implementation(projects.domain.markets.models)
implementation(projects.domain.wallets)
implementation(projects.domain.appCurrency)
implementation(projects.domain.account)
implementation(projects.domain.account.status)
}

View file

@ -0,0 +1,14 @@
package com.tangem.domain.search.model
import com.tangem.domain.models.currency.CryptoCurrency
/**
* @property timestamp epoch milliseconds
*/
data class RecentSearchToken(
val id: CryptoCurrency.RawID,
val name: String,
val symbol: String,
val imageUrl: String?,
val timestamp: Long,
)

View file

@ -0,0 +1,10 @@
package com.tangem.domain.search.model
import com.tangem.domain.markets.TokenMarket
data class SearchResult(
val textHints: List<SearchTextHint>,
val recentTokens: List<RecentSearchToken>,
val userAssets: List<UserAssetSearchEntry>,
val marketTokens: List<TokenMarket>,
)

View file

@ -0,0 +1,6 @@
package com.tangem.domain.search.model
/**
* @property timestamp epoch milliseconds
*/
data class SearchTextHint(val text: String, val timestamp: Long)

View file

@ -0,0 +1,14 @@
package com.tangem.domain.search.model
import com.tangem.domain.models.account.AccountId
import com.tangem.domain.models.account.AccountName
import com.tangem.domain.models.currency.CryptoCurrencyStatus
import com.tangem.domain.models.wallet.UserWalletId
data class UserAssetSearchEntry(
val userWalletId: UserWalletId,
val userWalletName: String,
val accountId: AccountId,
val accountName: AccountName,
val currencyStatus: CryptoCurrencyStatus,
)

View file

@ -0,0 +1,39 @@
package com.tangem.domain.search.repository
import com.tangem.domain.search.model.RecentSearchToken
import com.tangem.domain.search.model.SearchTextHint
import kotlinx.coroutines.flow.Flow
/**
* Repository responsible for managing local search history storage.
* Handles persistence of user's past search queries and recently viewed market tokens.
* Each history type is limited to 3 entries, sorted by timestamp in descending order.
*/
interface SearchRepository {
/** Observes the list of saved text hints, sorted by timestamp descending. */
fun getTextHints(): Flow<List<SearchTextHint>>
/** Observes the list of recently viewed market tokens, sorted by timestamp descending. */
fun getRecentTokens(): Flow<List<RecentSearchToken>>
/**
* Saves a text hint to the search history.
* If the hint already exists, its timestamp is updated. Oldest entries are evicted when the limit is exceeded.
*
* @param text the search query text to save
*/
suspend fun saveTextHint(text: String)
/**
* Saves a recently viewed market token to the search history.
* If a token with the same ID already exists, it is moved to the top. Oldest entries are evicted when the limit
* is exceeded.
*
* @param token the market token entry to save
*/
suspend fun saveRecentToken(token: RecentSearchToken)
/** Clears all search history, including both text hints and recent tokens. */
suspend fun clearHistory()
}

View file

@ -0,0 +1,17 @@
package com.tangem.domain.search.usecase
import com.tangem.domain.search.repository.SearchRepository
/**
* Clears the entire search history, removing both text hints and recently viewed tokens.
*
* @property searchRepository local search history storage
*/
class ClearSearchHistoryUseCase(
private val searchRepository: SearchRepository,
) {
suspend operator fun invoke() {
searchRepository.clearHistory()
}
}

View file

@ -0,0 +1,126 @@
package com.tangem.domain.search.usecase
import com.tangem.domain.account.models.AccountStatusList
import com.tangem.domain.account.status.supplier.MultiAccountStatusListSupplier
import com.tangem.domain.common.wallets.UserWalletsListRepository
import com.tangem.domain.markets.TokenMarket
import com.tangem.domain.models.account.filterCryptoPortfolio
import com.tangem.domain.models.wallet.UserWallet
import com.tangem.domain.models.wallet.UserWalletId
import com.tangem.domain.models.wallet.isLocked
import com.tangem.domain.search.model.SearchResult
import com.tangem.domain.search.model.UserAssetSearchEntry
import com.tangem.domain.search.repository.SearchRepository
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.combine
import kotlinx.coroutines.flow.flowOf
/**
* Primary search use case that produces [SearchResult] based on the current query.
*
* Behavior depends on the query:
* - **Empty query** returns search history: text hints and recently viewed tokens.
* - **Non-empty query** performs the search across all unlocked user wallets,
* matching currencies by name or symbol, and combines the results with externally provided market tokens.
*
* @property searchRepository local search history storage
* @property multiAccountStatusListSupplier supplier for loaded account status lists across all wallets
* @property userWalletsListRepository repository providing the list of user wallets
*/
class GetSearchResultsUseCase(
private val searchRepository: SearchRepository,
private val multiAccountStatusListSupplier: MultiAccountStatusListSupplier,
private val userWalletsListRepository: UserWalletsListRepository,
) {
/**
* Produces a [Flow] of [SearchResult] for the given [query].
*
* @param query the search query string; blank means "show history"
* @param marketTokens external flow of market token search results (provided by presentation layer)
*/
operator fun invoke(
query: String,
marketTokens: Flow<List<TokenMarket>> = flowOf(emptyList()),
): Flow<SearchResult> {
return if (query.isBlank()) {
observeHistory()
} else {
searchAssets(query, marketTokens)
}
}
private fun observeHistory(): Flow<SearchResult> {
return combine(
searchRepository.getTextHints(),
searchRepository.getRecentTokens(),
) { hints, tokens ->
SearchResult(
textHints = hints,
recentTokens = tokens,
userAssets = emptyList(),
marketTokens = emptyList(),
)
}
}
private fun searchAssets(query: String, marketTokens: Flow<List<TokenMarket>>): Flow<SearchResult> {
return combine(
observeUserAssets(query),
marketTokens,
) { userAssets, markets ->
SearchResult(
textHints = emptyList(),
recentTokens = emptyList(),
userAssets = userAssets,
marketTokens = markets,
)
}
}
private fun observeUserAssets(query: String): Flow<List<UserAssetSearchEntry>> {
val lowerQuery = query.lowercase()
return combine(
multiAccountStatusListSupplier(),
userWalletsListRepository.userWallets,
) { statusLists, wallets ->
val unlockedWallets = wallets
.orEmpty()
.filterNot(UserWallet::isLocked)
.associateBy { it.walletId }
if (unlockedWallets.isEmpty()) return@combine emptyList()
statusLists
.filter { it.userWalletId in unlockedWallets }
.flatMap { statusList -> extractMatchingAssets(statusList, unlockedWallets, lowerQuery) }
}
}
private fun extractMatchingAssets(
statusList: AccountStatusList,
wallets: Map<UserWalletId, UserWallet>,
lowerQuery: String,
): List<UserAssetSearchEntry> {
val wallet = wallets[statusList.userWalletId] ?: return emptyList()
return statusList.accountStatuses
.filterCryptoPortfolio()
.flatMap { accountStatus ->
accountStatus.flattenCurrencies()
.filter { currencyStatus ->
val name = currencyStatus.currency.name.lowercase()
val symbol = currencyStatus.currency.symbol.lowercase()
name.contains(lowerQuery) || symbol.contains(lowerQuery)
}
.map { currencyStatus ->
UserAssetSearchEntry(
userWalletId = statusList.userWalletId,
userWalletName = wallet.name,
accountId = accountStatus.accountId,
accountName = accountStatus.account.accountName,
currencyStatus = currencyStatus,
)
}
}
}
}

View file

@ -0,0 +1,23 @@
package com.tangem.domain.search.usecase
import com.tangem.domain.search.model.RecentSearchToken
import com.tangem.domain.search.repository.SearchRepository
/**
* Saves a market token to the "recently viewed" search history.
* Only market assets should be saved (user's own assets are ignored).
* The history is limited to 3 entries; oldest entries are evicted automatically.
*
* @property searchRepository local search history storage
*/
class SaveRecentSearchTokenUseCase(
private val searchRepository: SearchRepository,
) {
/**
* @param token the market token to persist as a recent search entry
*/
suspend operator fun invoke(token: RecentSearchToken) {
searchRepository.saveRecentToken(token)
}
}

View file

@ -0,0 +1,23 @@
package com.tangem.domain.search.usecase
import com.tangem.domain.search.repository.SearchRepository
/**
* Saves the current search query text to the local search history.
* Blank queries are ignored. The history is limited to 3 entries; oldest entries are evicted automatically.
* Should be called when the user selects any asset from the search results.
*
* @property searchRepository local search history storage
*/
class SaveSearchQueryUseCase(
private val searchRepository: SearchRepository,
) {
/**
* @param query the search text to persist; blank values are silently ignored
*/
suspend operator fun invoke(query: String) {
if (query.isBlank()) return
searchRepository.saveTextHint(query.trim())
}
}

View file

@ -22,7 +22,6 @@ dependencies {
implementation(deps.kotlin.datetime)
implementation(deps.kotlin.serialization)
implementation(deps.jodatime)
implementation(deps.timber)
implementation(projects.domain.legacy)
implementation(projects.domain.walletManager) // TODO refactor to use from data module

View file

@ -7,7 +7,7 @@ import com.tangem.domain.models.staking.StakingBalance
import com.tangem.domain.models.staking.StakingID
import com.tangem.domain.models.wallet.UserWalletId
import com.tangem.utils.extensions.indexOfFirstOrNull
import timber.log.Timber
import com.tangem.utils.logging.TangemLogger
/**
* Producer of staking balance for selected wallet [UserWalletId]
@ -51,9 +51,8 @@ interface SingleStakingBalanceProducer : FlowProducer<StakingBalance> {
),
)
Timber.e(
"Multiple balances found for staking ID $currentStakingId:\n%s",
currentBalances.joinToString("\n"),
TangemLogger.e(
"Multiple balances found for staking ID $currentStakingId:\n${currentBalances.joinToString("\n")}",
)
val dataIndex = currentBalances.indexOfFirstOrNull { it is StakingBalance.Data }
@ -66,7 +65,7 @@ interface SingleStakingBalanceProducer : FlowProducer<StakingBalance> {
} else {
val balance = currentBalances.firstOrNull() ?: return null
Timber.i("Staking balance found for $currentStakingId:\n$balance")
TangemLogger.i("Staking balance found for $currentStakingId:\n$balance")
balance
}
}

View file

@ -1,6 +1,7 @@
package com.tangem.domain.swap.models
import com.tangem.domain.express.models.ExpressProvider
import com.tangem.domain.express.models.ExpressRateType
import com.tangem.domain.models.currency.CryptoCurrencyStatus
/**
@ -50,4 +51,16 @@ data class SwapCurrenciesGroup(
data class SwapCryptoCurrency(
val currencyStatus: CryptoCurrencyStatus,
val providers: List<ExpressProvider>,
)
)
/**
* Get initial rate type based on available providers
*/
fun List<ExpressProvider>.getInitialRateType(): ExpressRateType {
val availableRateTypes = this.flatMap { it.rateTypes }.toSet()
return if (availableRateTypes.contains(ExpressRateType.Fixed)) {
ExpressRateType.Fixed
} else {
ExpressRateType.Float
}
}

View file

@ -8,7 +8,7 @@ import java.math.BigDecimal
*
* @property provider swap provider
* @property toTokenAmount amount of token you want to receive
* @property fromTokenAmount amount of from-token required (for fixed rate quotes)
* @property fromTokenAmount amount of from-token required (only set for fixed rate quotes)
* @property allowanceContract whether swap occurs via third token
*/
data class SwapQuoteModel(
@ -16,4 +16,5 @@ data class SwapQuoteModel(
val toTokenAmount: BigDecimal,
val fromTokenAmount: BigDecimal?,
val allowanceContract: String?,
val quoteId: String? = null,
)

View file

@ -91,6 +91,7 @@ interface SwapRepositoryV2 {
expressProvider: ExpressProvider,
rateType: ExpressRateType,
expressOperationType: ExpressOperationType,
quoteId: String?,
): SwapDataModel
/**

View file

@ -30,6 +30,7 @@ class GetSwapDataUseCase(
expressProvider: ExpressProvider,
rateType: ExpressRateType,
expressOperationType: ExpressOperationType,
quoteId: String? = null,
): Either<ExpressError, SwapDataModel> = Either.catch {
swapRepositoryV2.getSwapData(
userWallet = userWallet,
@ -42,6 +43,7 @@ class GetSwapDataUseCase(
expressProvider = expressProvider,
rateType = rateType,
expressOperationType = expressOperationType,
quoteId = quoteId,
)
}.mapLeft { throwable ->
swapErrorResolver.resolve(throwable)

View file

@ -53,7 +53,6 @@ dependencies {
/** Android - Other */
implementation(deps.androidx.paging.runtime)
implementation(deps.timber)
/** Utils */
implementation(deps.jodatime)

View file

@ -1,14 +0,0 @@
package com.tangem.domain.tokens.model.tokensync
import java.math.BigDecimal
data class DiscoveredToken(
val contractAddress: String?,
val symbol: String,
val name: String,
val decimals: Int,
val amount: BigDecimal,
val isNativeToken: Boolean,
val currencyId: String?,
val networkId: String,
)

View file

@ -1,22 +0,0 @@
package com.tangem.domain.tokens.model.tokensync
sealed class TokenSyncProgress {
data object Idle : TokenSyncProgress()
data class InProgress(
val completedNetworks: Int,
val totalNetworks: Int,
) : TokenSyncProgress() {
val progressPercent: Int
get() = if (totalNetworks > 0) {
completedNetworks * 100 / totalNetworks
} else {
0
}
}
data object Completed : TokenSyncProgress()
data class Error(val cause: Throwable) : TokenSyncProgress()
}

View file

@ -11,4 +11,5 @@ data class CryptoCurrencyCheck(
val utxoAmountLimit: UtxoAmountLimit?,
val isAccountFunded: Boolean,
val rentWarning: CryptoCurrencyWarning.Rent?,
val isMemoRequired: Boolean = false,
)

View file

@ -8,10 +8,10 @@ import com.tangem.domain.networks.multi.MultiNetworkStatusFetcher
import com.tangem.domain.quotes.multi.MultiQuoteStatusFetcher
import com.tangem.domain.staking.StakingIdFactory
import com.tangem.domain.staking.multi.MultiStakingBalanceFetcher
import com.tangem.utils.logging.TangemLogger
import kotlinx.coroutines.async
import kotlinx.coroutines.awaitAll
import kotlinx.coroutines.coroutineScope
import timber.log.Timber
/**
* Shared utility for fetching cryptocurrency balance data from multiple sources.
@ -116,14 +116,14 @@ class BalanceFetchingOperations(
val stakingId = stakingIdFactory.create(userWalletId = userWalletId, cryptoCurrency = currency)
if (stakingId.isLeft { it is StakingIdFactory.Error.UnableToGetAddress }) {
Timber.e("Unable to get staking ID for user wallet $userWalletId and currency ${currency.id}")
TangemLogger.e("Unable to get staking ID for user wallet $userWalletId and currency ${currency.id}")
}
stakingId.getOrNull()
}
if (stakingIds.isEmpty()) {
Timber.i("No staking IDs found for user wallet $userWalletId")
TangemLogger.i("No staking IDs found for user wallet $userWalletId")
return Unit.right()
}

View file

@ -45,6 +45,14 @@ class GetCurrencyCheckUseCase(
} else {
false
}
val isMemoRequired = if (recipientAddress != null) {
currencyChecksRepository.checkIfMemoRequired(
network = network,
address = recipientAddress,
)
} else {
false
}
val utxoAmountLimit = if (currency is CryptoCurrency.Coin && amount != null && fee != null) {
currencyChecksRepository.checkUtxoAmountLimit(
userWalletId = userWalletId,
@ -65,6 +73,7 @@ class GetCurrencyCheckUseCase(
utxoAmountLimit = utxoAmountLimit,
isAccountFunded = isAccountFunded,
rentWarning = rentWarning,
isMemoRequired = isMemoRequired,
)
}
}

View file

@ -5,6 +5,4 @@ package com.tangem.domain.tokens
*
[REDACTED_AUTHOR]
*/
interface TokensFeatureToggles {
val isMultiAddressUtxoEnabled: Boolean
}
interface TokensFeatureToggles

View file

@ -35,6 +35,9 @@ interface CurrencyChecksRepository {
/** Returns true if account with [address] was reserved with minimum amount */
suspend fun checkIfAccountFunded(userWalletId: UserWalletId, network: Network, address: String): Boolean
/** Returns true if a memo/destination tag is required for the given [address] on [network] */
suspend fun checkIfMemoRequired(network: Network, address: String): Boolean
/** Checks if transaction amount is within the UTXO limit */
suspend fun checkUtxoAmountLimit(
userWalletId: UserWalletId,

View file

@ -1,23 +0,0 @@
package com.tangem.domain.tokens.repository
import com.tangem.domain.models.currency.CryptoCurrency
import com.tangem.domain.models.wallet.UserWalletId
import com.tangem.domain.tokens.model.tokensync.TokenSyncProgress
import kotlinx.coroutines.flow.Flow
interface TokenSyncRepository {
suspend fun runSync(userWalletId: UserWalletId)
suspend fun getPendingSyncWalletIds(): List<UserWalletId>
fun observeSyncProgress(userWalletId: UserWalletId): Flow<TokenSyncProgress>
fun acknowledgeCompletion(userWalletId: UserWalletId)
suspend fun clearPendingFlag(userWalletId: UserWalletId)
suspend fun getDiscoveredCurrencies(userWalletId: UserWalletId): List<CryptoCurrency>
suspend fun clearDiscoveredTokens(userWalletId: UserWalletId)
}

View file

@ -26,10 +26,10 @@ import com.tangem.domain.tokens.wallet.implementor.MultiWalletBalanceFetcher
import com.tangem.domain.tokens.wallet.implementor.SingleWalletBalanceFetcher
import com.tangem.domain.tokens.wallet.implementor.SingleWalletWithTokenBalanceFetcher
import com.tangem.utils.coroutines.CoroutineDispatcherProvider
import com.tangem.utils.logging.TangemLogger
import kotlinx.coroutines.async
import kotlinx.coroutines.awaitAll
import kotlinx.coroutines.coroutineScope
import timber.log.Timber
/**
* Fetcher of wallet balance by [UserWalletId]
@ -184,7 +184,7 @@ class WalletBalanceFetcher internal constructor(
check(errors.isEmpty()) {
val message = FetchErrorFormatter.formatWalletErrors(userWalletId, errors)
Timber.e(message)
TangemLogger.e(message)
message
}
}

View file

@ -2,14 +2,14 @@ package com.tangem.domain.tokens.wallet.implementor
import com.tangem.domain.models.currency.CryptoCurrency
import com.tangem.domain.models.wallet.UserWallet
import com.tangem.domain.tokens.FetchingSource
import com.tangem.domain.tokens.MultiWalletAccountListFetcher
import com.tangem.domain.tokens.MultiWalletCryptoCurrenciesProducer
import com.tangem.domain.tokens.MultiWalletCryptoCurrenciesSupplier
import com.tangem.domain.tokens.FetchingSource
import com.tangem.domain.tokens.wallet.BaseWalletBalanceFetcher
import com.tangem.domain.tokens.wallet.WalletFetchingSource
import com.tangem.utils.logging.TangemLogger
import kotlinx.coroutines.flow.firstOrNull
import timber.log.Timber
/**
* Implementation of [BaseWalletBalanceFetcher] for MULTI-CURRENCY wallet
@ -37,7 +37,7 @@ internal class MultiWalletBalanceFetcher(
multiWalletAccountListFetcher(
params = MultiWalletAccountListFetcher.Params(userWalletId = userWalletId),
)
.onLeft(Timber::e)
.onLeft { TangemLogger.e("Error", it) }
return multiWalletCryptoCurrenciesSupplier(
params = MultiWalletCryptoCurrenciesProducer.Params(userWalletId = userWalletId),

View file

@ -13,6 +13,7 @@ internal object MockQuotes {
rawCurrencyId = MockTokens.token1.id.rawCurrencyId!!,
value = QuoteStatus.Data(
fiatRate = BigDecimal("1.23"),
fiatRateUSD = BigDecimal("1.23"),
priceChange = BigDecimal("0.01"),
source = StatusSource.ACTUAL,
),
@ -22,6 +23,7 @@ internal object MockQuotes {
rawCurrencyId = MockTokens.token2.id.rawCurrencyId!!,
value = QuoteStatus.Data(
fiatRate = BigDecimal("2.34"),
fiatRateUSD = BigDecimal("2.34"),
priceChange = BigDecimal("-0.02"),
source = StatusSource.ACTUAL,
),
@ -31,6 +33,7 @@ internal object MockQuotes {
rawCurrencyId = MockTokens.token3.id.rawCurrencyId!!,
value = QuoteStatus.Data(
fiatRate = BigDecimal("3.45"),
fiatRateUSD = BigDecimal("3.45"),
priceChange = BigDecimal("0.03"),
source = StatusSource.ACTUAL,
),
@ -40,6 +43,7 @@ internal object MockQuotes {
rawCurrencyId = MockTokens.token4.id.rawCurrencyId!!,
value = QuoteStatus.Data(
fiatRate = BigDecimal("4.56"),
fiatRateUSD = BigDecimal("4.56"),
priceChange = BigDecimal("-0.04"),
source = StatusSource.ACTUAL,
),
@ -49,6 +53,7 @@ internal object MockQuotes {
rawCurrencyId = MockTokens.token5.id.rawCurrencyId!!,
value = QuoteStatus.Data(
fiatRate = BigDecimal("5.67"),
fiatRateUSD = BigDecimal("5.67"),
priceChange = BigDecimal("0.05"),
source = StatusSource.ACTUAL,
),
@ -58,6 +63,7 @@ internal object MockQuotes {
rawCurrencyId = MockTokens.token6.id.rawCurrencyId!!,
value = QuoteStatus.Data(
fiatRate = BigDecimal("6.78"),
fiatRateUSD = BigDecimal("6.78"),
priceChange = BigDecimal("-0.06"),
source = StatusSource.ACTUAL,
),
@ -67,6 +73,7 @@ internal object MockQuotes {
rawCurrencyId = MockTokens.token7.id.rawCurrencyId!!,
value = QuoteStatus.Data(
fiatRate = BigDecimal("7.89"),
fiatRateUSD = BigDecimal("7.89"),
priceChange = BigDecimal("0.07"),
source = StatusSource.ACTUAL,
),
@ -76,6 +83,7 @@ internal object MockQuotes {
rawCurrencyId = MockTokens.token8.id.rawCurrencyId!!,
value = QuoteStatus.Data(
fiatRate = BigDecimal("8.90"),
fiatRateUSD = BigDecimal("8.90"),
priceChange = BigDecimal("-0.08"),
source = StatusSource.ACTUAL,
),
@ -85,6 +93,7 @@ internal object MockQuotes {
rawCurrencyId = MockTokens.token9.id.rawCurrencyId!!,
value = QuoteStatus.Data(
fiatRate = BigDecimal("9.01"),
fiatRateUSD = BigDecimal("9.01"),
priceChange = BigDecimal("0.09"),
source = StatusSource.ACTUAL,
),
@ -94,6 +103,7 @@ internal object MockQuotes {
rawCurrencyId = MockTokens.token10.id.rawCurrencyId!!,
value = QuoteStatus.Data(
fiatRate = BigDecimal("10.12"),
fiatRateUSD = BigDecimal("10.12"),
priceChange = BigDecimal("-0.10"),
source = StatusSource.ACTUAL,
),

View file

@ -44,6 +44,7 @@ class CryptoCurrencyStatusFactoryTest {
private val fullQuote = QuoteStatus.Data(
fiatRate = 1800.0.toBigDecimal(),
fiatRateUSD = 1800.0.toBigDecimal(),
priceChange = (-2.5).toBigDecimal(),
source = StatusSource.ACTUAL,
)

View file

@ -16,5 +16,4 @@ dependencies {
implementation(deps.kotlin.coroutines)
implementation(deps.arrow.core)
implementation(deps.timber)
}

View file

@ -17,6 +17,4 @@ sealed class TokenSyncProgress {
}
data object Completed : TokenSyncProgress()
data class Error(val cause: Throwable) : TokenSyncProgress()
}

View file

@ -9,6 +9,8 @@ interface TokenSyncRepository {
suspend fun runSync(userWalletId: UserWalletId)
suspend fun completeSync(userWalletId: UserWalletId)
suspend fun getPendingSyncWalletIds(): List<UserWalletId>
fun observeSyncProgress(userWalletId: UserWalletId): Flow<TokenSyncProgress>

View file

@ -6,12 +6,12 @@ import com.tangem.domain.models.account.AccountId
import com.tangem.domain.models.wallet.UserWalletId
import com.tangem.domain.tokensync.repository.TokenSyncRepository
import com.tangem.utils.coroutines.AppCoroutineScope
import com.tangem.utils.logging.TangemLogger
import kotlinx.coroutines.Job
import kotlinx.coroutines.launch
import timber.log.Timber
import java.util.concurrent.ConcurrentHashMap
class SyncTokensUseCase(
class StartTokenSyncUseCase(
private val tokenSyncRepository: TokenSyncRepository,
private val manageCryptoCurrenciesUseCase: ManageCryptoCurrenciesUseCase,
private val appCoroutineScope: AppCoroutineScope,
@ -25,8 +25,9 @@ class SyncTokensUseCase(
try {
tokenSyncRepository.runSync(userWalletId)
applyDiscoveredTokens(userWalletId)
tokenSyncRepository.completeSync(userWalletId)
} catch (e: Exception) {
Timber.e(e, "Token sync failed for wallet: $userWalletId")
TangemLogger.e("Token sync failed for wallet: $userWalletId", e)
} finally {
activeSyncJobs.remove(userWalletId)
}
@ -50,7 +51,7 @@ class SyncTokensUseCase(
}
}
} catch (e: Exception) {
Timber.e(e, "Failed to apply pending syncs")
TangemLogger.e("Failed to apply pending syncs", e)
}
}
}
@ -61,7 +62,7 @@ class SyncTokensUseCase(
if (currencies.isEmpty()) return true
val accountId = AccountId.forMainCryptoPortfolio(userWalletId)
return manageCryptoCurrenciesUseCase(
return manageCryptoCurrenciesUseCase.invokeAndAwait(
accountId = accountId,
add = currencies,
).fold(
@ -70,7 +71,7 @@ class SyncTokensUseCase(
true
},
ifLeft = { error ->
Timber.e("Failed to apply discovered tokens for wallet: $userWalletId, error: $error")
TangemLogger.e("Failed to apply discovered tokens for wallet: $userWalletId, error: $error")
false
},
)

View file

@ -0,0 +1,46 @@
package com.tangem.domain.transaction
import com.tangem.domain.models.currency.CryptoCurrency
import com.tangem.domain.models.wallet.UserWalletId
import com.tangem.domain.transaction.models.AllowanceInfo
import java.math.BigDecimal
/**
* Repository interface for managing token allowances in the context of blockchain transactions.
*/
interface AllowanceRepository {
/**
* Retrieves the allowance information for a specific spender and required amount.
*
* @param userWalletId The ID of the user's wallet.
* @param cryptoCurrency The cryptocurrency for which the allowance is being checked (must be a token).
* @param spenderAddress The address of the spender for whom the allowance is being checked.
* @param requiredAmount The amount that is required for the transaction.
*
* @return An [AllowanceInfo] object that indicates whether the current allowance.
* @throws IllegalStateException if the provided [cryptoCurrency] is not a token.
*/
suspend fun getAllowanceInfo(
userWalletId: UserWalletId,
cryptoCurrency: CryptoCurrency,
spenderAddress: String,
requiredAmount: BigDecimal,
): AllowanceInfo
/**
* Retrieves the current allowance for a specific spender.
*
* @param userWalletId The ID of the user's wallet.
* @param cryptoCurrency The cryptocurrency for which the allowance is being checked (must be a token).
* @param spenderAddress The address of the spender for whom the allowance is being checked.
*
* @return The current allowance as a [BigDecimal].
* @throws IllegalStateException if the provided [cryptoCurrency] is not a token.
*/
suspend fun getAllowance(
userWalletId: UserWalletId,
cryptoCurrency: CryptoCurrency,
spenderAddress: String,
): BigDecimal
}

View file

@ -0,0 +1,21 @@
package com.tangem.domain.transaction
import com.tangem.domain.models.network.Network
/**
* Facade for memo validation operations.
*/
interface MemoValidatorFacade {
/**
* Returns true if a memo/destination tag is required for the given [destinationAddress] on [network].
* Returns false on network errors or for unsupported blockchains.
*/
suspend fun isMemoRequired(network: Network, destinationAddress: String): Boolean
/**
* Returns true if [memo] is valid for the given [network], or if memo is not supported.
* Returns true on errors (lenient fallback).
*/
suspend fun validateMemo(network: Network, memo: String): Boolean
}

View file

@ -6,11 +6,9 @@ import com.tangem.blockchain.common.transaction.Fee
import com.tangem.blockchain.common.transaction.TransactionSendResult
import com.tangem.blockchain.common.transaction.TransactionsSendResult
import com.tangem.blockchain.nft.models.NFTAsset
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.transaction.models.EventTransactionTypeDto
import java.math.BigDecimal
import java.math.BigInteger
interface TransactionRepository {
@ -91,12 +89,6 @@ interface TransactionRepository {
gasLimit: BigInteger?,
): TransactionExtras
suspend fun getAllowance(
userWalletId: UserWalletId,
cryptoCurrency: CryptoCurrency.Token,
spenderAddress: String,
): BigDecimal
suspend fun prepareForSend(
transactionData: TransactionData,
signer: TransactionSigner,

View file

@ -23,7 +23,9 @@ interface WalletAddressServiceRepository {
suspend fun validateAddress(userWalletId: UserWalletId, network: Network, address: String): Boolean
suspend fun validateMemo(userWalletId: UserWalletId, network: Network, memo: String): Boolean
suspend fun validateMemo(network: Network, memo: String): Boolean
suspend fun isMemoRequired(network: Network, destinationAddress: String): Boolean
suspend fun parseSharedAddress(input: String, network: Network): ParsedQrCode
}

View file

@ -0,0 +1,25 @@
package com.tangem.domain.transaction.models
import java.math.BigDecimal
/**
* Model that represents the allowance information for a specific spender and required amount.
*/
sealed class AllowanceInfo {
/**
* Represents a state where the current allowance is sufficient to cover the required amount.
*/
data class Enough(val allowance: BigDecimal) : AllowanceInfo()
/**
* Represents a state where the current allowance is insufficient to cover the required amount.
*/
data class NotEnough(val allowance: BigDecimal, val requiredAmount: BigDecimal) : AllowanceInfo()
/**
* Represents a state where the current allowance is insufficient,
* but it must be reset to cover the required amount (specific to certain tokens like Tether in Ethereum).
*/
data class ResetNeeded(val allowance: BigDecimal, val requiredAmount: BigDecimal) : AllowanceInfo()
}

View file

@ -0,0 +1,32 @@
package com.tangem.domain.transaction.usecase
import arrow.core.Either
import com.tangem.domain.models.currency.CryptoCurrency
import com.tangem.domain.models.wallet.UserWalletId
import com.tangem.domain.transaction.AllowanceRepository
import com.tangem.domain.transaction.models.AllowanceInfo
import java.math.BigDecimal
/**
* Use case for retrieving the allowance information for a specific spender and required amount.
*/
class GetAllowanceInfoUseCase(
private val allowanceRepository: AllowanceRepository,
) {
suspend operator fun invoke(
userWalletId: UserWalletId,
cryptoCurrency: CryptoCurrency,
spenderAddress: String,
requiredAmount: BigDecimal,
): Either<Throwable, AllowanceInfo> {
return Either.catch {
allowanceRepository.getAllowanceInfo(
userWalletId = userWalletId,
cryptoCurrency = cryptoCurrency,
spenderAddress = spenderAddress,
requiredAmount = requiredAmount,
)
}
}
}

View file

@ -2,12 +2,15 @@ package com.tangem.domain.transaction.usecase
import arrow.core.Either
import com.tangem.domain.models.currency.CryptoCurrency
import com.tangem.domain.transaction.TransactionRepository
import com.tangem.domain.models.wallet.UserWalletId
import com.tangem.domain.transaction.AllowanceRepository
import java.math.BigDecimal
/**
* Use case for retrieving the current allowance for a specific spender.
*/
class GetAllowanceUseCase(
private val transactionRepository: TransactionRepository,
private val allowanceRepository: AllowanceRepository,
) {
suspend operator fun invoke(
@ -16,9 +19,9 @@ class GetAllowanceUseCase(
spenderAddress: String,
): Either<Throwable, BigDecimal> {
return Either.catch {
transactionRepository.getAllowance(
allowanceRepository.getAllowance(
userWalletId = userWalletId,
cryptoCurrency = cryptoCurrency as CryptoCurrency.Token,
cryptoCurrency = cryptoCurrency,
spenderAddress = spenderAddress,
)
}

View file

@ -22,7 +22,6 @@ class ValidateWalletMemoUseCase(
): Either<ValidateMemoError, Unit> {
return try {
val isValidMemo = walletAddressServiceRepository.validateMemo(
userWalletId = userWalletId,
network = cryptoCurrency.network,
memo = memo,
)

1
domain/virtual-account/.gitignore vendored Normal file
View file

@ -0,0 +1 @@
/build

View file

@ -0,0 +1,13 @@
plugins {
alias(deps.plugins.android.library)
alias(deps.plugins.kotlin.android)
alias(deps.plugins.ksp)
id("configuration")
}
android {
namespace = "com.tangem.domain.virtualaccount"
}
dependencies {
}

View file

@ -0,0 +1 @@
/build

View file

@ -0,0 +1,13 @@
plugins {
alias(deps.plugins.android.library)
alias(deps.plugins.kotlin.android)
alias(deps.plugins.ksp)
id("configuration")
}
android {
namespace = "com.tangem.domain.virtualaccount.models"
}
dependencies {
}

View file

@ -29,7 +29,6 @@ dependencies {
implementation(deps.spongecastle.core)
/** Libs - Other */
implementation(deps.timber)
implementation(deps.jodatime)
implementation(deps.androidx.paging.runtime)
implementation(deps.moshi)

View file

@ -1,66 +0,0 @@
package com.tangem.domain.pay
import com.tangem.domain.models.StatusSource
import com.tangem.domain.models.kyc.KycStatus
import com.tangem.domain.models.serialization.SerializedBigDecimal
import kotlinx.serialization.Serializable
@Serializable
sealed class PaymentAccountStatus {
abstract val source: StatusSource
@Serializable
data object Loading : PaymentAccountStatus() {
override val source: StatusSource = StatusSource.ACTUAL
}
@Serializable
data object NotCreated : PaymentAccountStatus() {
override val source: StatusSource = StatusSource.ACTUAL
}
@Serializable
data class UnderReview(
override val source: StatusSource,
val kycStatus: KycStatus,
) : PaymentAccountStatus()
@Serializable
data class IssuingCard(override val source: StatusSource) : PaymentAccountStatus()
@Serializable
data class Locked(override val source: StatusSource) : PaymentAccountStatus()
@Serializable
data class Loaded(
override val source: StatusSource,
val cardId: String,
val lastFourDigits: String,
val balance: SerializedBigDecimal,
val currencyCode: String,
val depositAddress: String?,
val isPinSet: Boolean,
) : PaymentAccountStatus()
@Serializable
sealed class Error : PaymentAccountStatus() {
@Serializable
data object ExposedDevice : Error() {
override val source: StatusSource = StatusSource.ACTUAL
}
@Serializable
data class Unavailable(override val source: StatusSource) : Error()
@Serializable
data object NotSynced : Error() {
override val source: StatusSource = StatusSource.ACTUAL
}
@Serializable
data object CardIssueFailed : Error() {
override val source: StatusSource = StatusSource.ACTUAL
}
}
}

View file

@ -8,4 +8,5 @@ import com.tangem.domain.models.wallet.UserWallet
interface TangemPayCryptoCurrencyFactory {
fun create(userWallet: UserWallet, chainId: Int): Either<UniversalError, CryptoCurrency>
fun create(userWallet: UserWallet): Either<UniversalError, CryptoCurrency.Token>
}

View file

@ -1,10 +1,10 @@
package com.tangem.domain.pay.flow
import com.tangem.domain.core.flow.FlowProducer
import com.tangem.domain.models.account.AccountStatus
import com.tangem.domain.models.wallet.UserWalletId
import com.tangem.domain.pay.PaymentAccountStatus
interface PaymentAccountStatusProducer : FlowProducer<PaymentAccountStatus> {
interface PaymentAccountStatusProducer : FlowProducer<AccountStatus.Payment> {
data class Params(val userWalletId: UserWalletId)
interface Factory : FlowProducer.Factory<Params, PaymentAccountStatusProducer>

View file

@ -1,10 +1,18 @@
package com.tangem.domain.pay.flow
import com.tangem.domain.core.flow.FlowCachingSupplier
import com.tangem.domain.pay.PaymentAccountStatus
import com.tangem.domain.models.account.AccountStatus
import com.tangem.domain.models.wallet.UserWalletId
import kotlinx.coroutines.flow.Flow
@Suppress("UnnecessaryAbstractClass")
abstract class PaymentAccountStatusSupplier(
override val factory: PaymentAccountStatusProducer.Factory,
override val keyCreator: (PaymentAccountStatusProducer.Params) -> String,
) : FlowCachingSupplier<PaymentAccountStatusProducer, PaymentAccountStatusProducer.Params, PaymentAccountStatus>()
) : FlowCachingSupplier<PaymentAccountStatusProducer, PaymentAccountStatusProducer.Params, AccountStatus.Payment>() {
operator fun invoke(userWalletId: UserWalletId): Flow<AccountStatus.Payment> {
val params = PaymentAccountStatusProducer.Params(userWalletId)
return this.invoke(params)
}
}

View file

@ -1,6 +1,8 @@
package com.tangem.domain.pay.model
import com.tangem.domain.models.account.PaymentAccountStatusValue
import com.tangem.domain.models.kyc.KycStatus
import com.tangem.domain.visa.model.TangemPayCardFrozenState
import java.math.BigDecimal
sealed class MainCustomerInfoContentState {
@ -25,6 +27,7 @@ data class CustomerInfo(
data class ProductInstance(
val id: String,
val cardId: String,
val frozenState: TangemPayCardFrozenState,
)
data class CardInfo(
@ -33,5 +36,7 @@ data class CustomerInfo(
val currencyCode: String,
val depositAddress: String?,
val isPinSet: Boolean,
val fiatBalance: PaymentAccountStatusValue.FiatBalance,
val cryptoBalance: PaymentAccountStatusValue.CryptoBalance,
)
}

View file

@ -12,10 +12,8 @@ import com.tangem.domain.pay.repository.OnboardingRepository
import com.tangem.domain.visa.error.VisaApiError
import com.tangem.security.DeviceSecurityInfoProvider
import com.tangem.security.isSecurityExposed
import com.tangem.utils.logging.TangemLogger
import kotlinx.coroutines.flow.*
import timber.log.Timber
private const val TAG = "TangemPayMainScreenCustomerInfoUseCase"
class TangemPayMainScreenCustomerInfoUseCase(
private val onboardingRepository: OnboardingRepository,
@ -27,13 +25,15 @@ class TangemPayMainScreenCustomerInfoUseCase(
val state: StateFlow<Map<UserWalletId, Either<TangemPayCustomerInfoError, MainCustomerInfoContentState>>>
field = MutableStateFlow(value = mapOf())
private val logger = TangemLogger.withTag("TangemPayMainScreenCustomerInfoUseCase")
suspend fun fetch(userWalletId: UserWalletId) {
Timber.tag(TAG).i("fetch: ${userWalletId.stringValue}")
logger.i("fetch: ${userWalletId.stringValue}")
if (deviceSecurity.isSecurityExposed()) {
Timber.tag(TAG).i("fetch security info: rooted: ${deviceSecurity.isRooted}")
Timber.tag(TAG).i("fetch security info: xposed: ${deviceSecurity.isXposed}")
Timber.tag(TAG).i("fetch security info: bootloader unlocked: ${deviceSecurity.isBootloaderUnlocked}")
logger.i("fetch security info: rooted: ${deviceSecurity.isRooted}")
logger.i("fetch security info: xposed: ${deviceSecurity.isXposed}")
logger.i("fetch security info: bootloader unlocked: ${deviceSecurity.isBootloaderUnlocked}")
updateState(userWalletId = userWalletId, either = TangemPayCustomerInfoError.ExposedDeviceError.left())
return // fast exit
@ -42,7 +42,7 @@ class TangemPayMainScreenCustomerInfoUseCase(
onboardingRepository.hasTangemPayInWallet(userWalletId)
.fold(
ifLeft = { error ->
Timber.tag(TAG).e("Failed checkCustomerWallet for $userWalletId: ${error.javaClass.simpleName}")
logger.e("Failed checkCustomerWallet for $userWalletId: ${error.javaClass.simpleName}")
if (error is VisaApiError.NotPaeraCustomer) {
showOnboardingBannerIfEligible(userWalletId)
} else {
@ -50,7 +50,7 @@ class TangemPayMainScreenCustomerInfoUseCase(
}
},
ifRight = { hasTangemPay ->
Timber.tag(TAG).i("checkCustomerWallet for $userWalletId: $hasTangemPay")
logger.i("checkCustomerWallet for $userWalletId: $hasTangemPay")
if (hasTangemPay) {
val oldResult = state.value[userWalletId]
if (oldResult == null) {
@ -125,14 +125,13 @@ class TangemPayMainScreenCustomerInfoUseCase(
): Either<TangemPayCustomerInfoError, MainScreenCustomerInfo> {
return onboardingRepository.getCustomerInfo(userWalletId)
.mapLeft { error ->
Timber.tag(TAG).e("mapErrorForCustomer: $error")
logger.e("mapErrorForCustomer: $error")
error.mapErrorForCustomer()
}
.map { customerInfo ->
Timber.tag(TAG).i("customerInfo")
logger.i("customerInfo")
if (customerInfo.productInstance == null) {
onboardingRepository.createOrder(userWalletId)
Timber.tag("ddk9499").d("TangemPayMainScreenCustomerInfoUseCase.proceedWithoutOrder: ")
MainScreenCustomerInfo(info = customerInfo, orderStatus = OrderStatus.NEW)
} else {
MainScreenCustomerInfo(info = customerInfo, orderStatus = OrderStatus.COMPLETED)

View file

@ -52,7 +52,6 @@ dependencies {
/** Other libraries */
implementation(platform(deps.firebase.bom))
implementation(deps.firebase.analytics)
implementation(deps.timber)
// region DI
implementation(deps.hilt.android)

View file

@ -12,13 +12,14 @@ dependencies {
// endregion
// region Domain modules
implementation(project(":domain:models"))
implementation(projects.domain.models)
// endregion
implementation(projects.core.utils)
// region Other libraries
implementation(deps.kotlin.serialization)
implementation(deps.moshi.kotlin)
implementation(deps.timber)
ksp(deps.moshi.kotlin.codegen)
// endregion
}

Some files were not shown because too many files have changed in this diff Show more