Updated on 2026-08-14

This commit is contained in:
Tangem 2026-05-22 18:12:20 +05:00
parent 470fc8c140
commit 3531c95908
67 changed files with 6736 additions and 5566 deletions

View file

@ -6,49 +6,56 @@ Token-to-token exchange feature. Users select FROM and TO tokens, get quotes fro
```
features/swap/
api/ — Public contracts (SwapComponent, SwapEntryComponent, SwapFeatureToggles)
api/ — Public contracts (SwapComponent, SwapFeatureToggles)
impl/ — UI, model, navigation, DI, token selection subfeature
domain/ — Business logic (SwapInteractor) + domain models
api/ — Domain interfaces
api/ — Domain interfaces (SwapRepository)
models/ — Domain model types (SwapPair, SwapProvider, SwapState, etc.)
fee/ — Fee calculation package (see Fee Architecture below)
data/ — Repository implementations, Retrofit APIs, Moshi DTOs
```
**Package naming:** API = `com.tangem.features.swap`, Impl = `com.tangem.feature.swap` (singular `feature`, legacy inconsistency).
**Build commands:**
```bash
./gradlew :features:swap:impl:compileDebugKotlin
./gradlew :features:swap:api:compileDebugKotlin
./gradlew :features:swap:domain:compileDebugKotlin
./gradlew :features:swap:domain:test
./gradlew :features:swap:impl:detekt
```
## Key Components
### SwapComponent (API)
Entry point. `Params` requires `currencyFrom`, `userWalletId`, `screenSource`. Optional: `currencyTo`, `isInitialReverseOrder`, `tangemPayInput`, `preselectedToToken`, `preselectedAccount`.
Entry point. `Params` requires `userWalletId`, optional `cryptoCurrency`, `screenSource`, `currencyPosition` (`FROM`/`TO`/`ANY`), and `tangemPayInput`.
### SwapEntryComponent (API)
Gateway component with sealed `Params`: `Story`, `Empty`, `Selected`, `Payment`. Routes to stories or directly to swap based on input type. See `entry/SwapEntryRoute.kt` for route definitions.
File: `features/swap/api/src/main/kotlin/com/tangem/features/swap/SwapComponent.kt`
### DefaultSwapComponent (impl)
Decompose component. Creates `SwapModel` via `getOrCreateModel(params)`.
**Child navigation:**
- `childStack(SwapRoute)` for screen navigation`SwapRoute.Main`, `SwapRoute.Success`, `SwapRoute.SelectToken(isFromDirection)` rendered via `Children` composable with fade animation
- `SlotNavigation<Unit>` for approval bottom sheet (`GiveApprovalComponent`)
- `SlotNavigation<FeeSelectorConfig>` for fee selector block
- `childStack(SwapRoute)``SwapRoute.Main`, `SwapRoute.Success`, `SwapRoute.SelectToken(isFromDirection)`, rendered via `Children` with fade animation
- `SlotNavigation<Unit>` approval bottom sheet (`GiveApprovalComponent`)
- `SlotNavigation<FeeSelectorConfig>` fee selector block
**Injected factories:** `SwapFeeSelectorBlockComponent.Factory`, `GiveApprovalComponent.Factory`, `ChooseTokenComponent.Factory`.
File: `features/swap/impl/src/main/java/com/tangem/feature/swap/DefaultSwapComponent.kt`
### SwapModel (impl)
`@ModelScoped`, extends `Model()`. The central coordinator — ~1500 lines.
`@ModelScoped`, extends `Model()`. Central coordinator — ~2100 lines.
**Key state:**
- `dataStateStateFlow: MutableStateFlow<SwapProcessDataState>` — reactive domain data (from/to tokens, pairs, providers, amounts, fees)
- `uiState: SwapStateHolder by mutableStateOf()` — Compose UI state built by `StateBuilder`
- `feeSelectorRepository: FeeSelectorRepository`fee state management
- `feeSelectorRepository: FeeSelectorRepository`inner class that implements `SwapFeeSelectorBlockComponent.ModelRepositoryExtended`; wires the fee selector UI component to `SwapInteractor.loadSwapFee` and `SwapInteractor.applySwapFee`
- `stackNavigation: StackNavigation<SwapRoute>` — stack navigation exposed from `SwapRouter`
- `approvalSlotNavigation: SlotNavigation<Unit>` — approval bottom sheet
**Navigation:**
- `SwapRouter` wraps `AppRouter` + `StackNavigation<SwapRoute>` for screen switching and back navigation
- `swapRouter.openScreen(SwapRoute.SelectToken(isFromDirection))` to push token selection
- `swapRouter.openScreen(SwapRoute.Success)` replaces current with success screen
- `swapRouter.back()` — pops local stack or exits swap via AppRouter
File: `features/swap/impl/src/main/java/com/tangem/feature/swap/model/SwapModel.kt`
**Initialization flow (init block):**
1. Subscribes to `chooseTokenBridge.onCurrencyChosen``onTokenSelect(result)`
@ -69,13 +76,26 @@ Decompose component. Creates `SwapModel` via `getOrCreateModel(params)`.
3. On approval done → reloads quotes
4. On swap success → `swapRouter.openScreen(SwapRoute.Success)`
### SwapProcessDataState (impl)
Data class holding the live domain state for the current swap session.
Key fields: `fromSwapCurrencyStatus`, `toSwapCurrencyStatus`, `feePaidCryptoCurrency`, `pairs: List<SwapPairLeast>`, `selectedProvider`, `lastLoadedSwapStates: Map<SwapProvider, SwapState>`, `swapDataModel: SwapDataModel?`, `amount: String?`, `reduceBalanceBy`.
`getCurrentLoadedSwapState()` — convenience to get `lastLoadedSwapStates[selectedProvider] as? QuotesLoadedState`.
File: `features/swap/impl/src/main/java/com/tangem/feature/swap/model/SwapProcessDataState.kt`
### StateBuilder (impl)
Pure transformation class. Takes `UiActions` + providers, builds `SwapStateHolder` from `SwapProcessDataState`.
Key methods: `createInitialLoadingState`, `createQuotesLoadedState`, `createSuccessState`, `loadingPermissionState`, `updateSwapAmount`, `addNotification`, `dismissBottomSheet`.
File: `features/swap/impl/src/main/java/com/tangem/feature/swap/ui/StateBuilder.kt`
### SwapRouter (impl)
Wraps `AppRouter` + `StackNavigation<SwapRoute>`. Handles `openScreen(SwapRoute)` to push/replace stack entries and `back()` with special logic: SelectToken pops local stack, Success exits to screen before SwapCrypto in app stack, Main pops AppRouter. `openTokenDetails()` navigates to `AppRoute.CurrencyDetails`.
Wraps `AppRouter` + `StackNavigation<SwapRoute>`. `openScreen(SwapRoute)` pushes/replaces stack entries. `back()` has special logic: SelectToken pops local stack, Success exits to the screen before SwapCrypto in the app stack, Main pops AppRouter. `openTokenDetails()` navigates to `AppRoute.CurrencyDetails`.
File: `features/swap/impl/src/main/java/com/tangem/feature/swap/router/SwapRoute.kt`
## Token Selection Subfeature (impl)
@ -88,40 +108,148 @@ Self-contained within `choosetoken/` package:
## Domain Layer
### SwapInteractor
Central domain interface. Methods:
- `getPair(from, to, filterProviderTypes)``Either<ExpressError, List<SwapPairLeast>>`
- `findBestQuote(from, to, providers, amount, ...)``Map<SwapProvider, SwapState>`
- `onSwap(from, to, provider, swapData, amount, fee, ...)``SwapTransactionState`
- `loadFeeForSwapTransaction(...)``Either<GetFeeError, TransactionFee/TransactionFeeExtended>`
- `getInitialCurrencyToSwap(accountStatusList, fromUserWallet, isReverse)``AccountCryptoCurrencyStatus?`
- `getTokenBalance(token)``SwapAmount`
### SwapInteractor (interface)
### Key Domain Models
- `SwapPairLeast` — from/to token info + providers list
- `SwapProvider` — providerId, name, type (DEX/CEX/DEX_BRIDGE), rates, slippage, TOS links
- `SwapState` — sealed: `QuotesLoadedState`, `SwapError`, `EmptyAmountState`
- `SwapCurrencyStatus` — wraps `CryptoCurrencyStatus` + `UserWallet` + `Account`
- `SwapAmount` — value + decimals pair
- `SwapDataModel` — quote result with transaction data
File: `features/swap/domain/src/main/java/com/tangem/feature/swap/domain/SwapInteractor.kt`
All public methods:
- `getPair(from, to, filterProviderTypes)``Either<ExpressError, List<SwapPairLeast>>`
- `findProvidersForPair(from, to, pairs)``List<SwapProvider>`
- `findProvidersForPairWithCheck(from, to, pairs)``List<SwapProvider>` (checks asset requirements/FCA)
- `findBestQuote(from, to, providers, amount, reduceBalanceBy)``Map<SwapProvider, SwapState>` (parallel per-provider)
- `onSwap(from, to, provider, swapData, amount, includeFeeInAmount, fee, operationType, isTangemPayWithdrawal)``SwapTransactionState`
- `loadSwapFee(provider, fromStatus, toStatus, amount, swapData, selectedFeeToken)``Either<GetFeeError, SwapFee>` — unified fee entry point (see Fee Architecture)
- `applySwapFee(state: QuotesLoadedState, fee: SwapFee)``QuotesLoadedState` — patches balance checks without re-fetching quotes
- `getTokenBalance(token)``SwapAmount`
- `getNativeToken(swapCurrencyStatus)``CryptoCurrency`
- `storeSwapTransaction(...)` — persists transaction for status tracking
### SwapInteractorImpl (impl)
File: `features/swap/domain/src/main/java/com/tangem/feature/swap/domain/SwapInteractorImpl.kt`
`@Inject` constructor with ~28 dependencies. Key injected components:
- `dexSwapFeeCalculator: DexSwapFeeCalculator` — fee calculation for DEX/DEX_BRIDGE
- `cexSwapFeeCalculator: CexSwapFeeCalculator` — fee calculation for CEX
`findBestQuote` dispatches per-provider using `supervisorScope + async`:
- `ExchangeProviderType.DEX` / `DEX_BRIDGE``manageDex(...)` or `manageDexSolana(...)`
- `ExchangeProviderType.CEX``manageCex(...)`
For DEX (non-Solana): if allowance OK and balance sufficient → `loadDexSwapDataNoFee(...)` which fetches exchange data but sets `feeState = NotEnough()` transiently. Fee is applied later via `applySwapFee`.
`onSwap` dispatch:
- CEX → `onSwapCex(...)` — fetches exchange data, then either `createAndSendGaslessTransactionUseCase` (token fee) or `sendTransactionUseCase` (native fee)
- DEX non-Solana → `onSwapDex(...)``createTransactionUseCase` with `createDexTxExtras(..., gasLimit = fee.fee.getGasLimit())`
- DEX Solana → compiled tx signed as-is; `fee` is only used for analytics/UI
### SwapTransferInteractor / SwapTransferInteractorImpl (domain)
Handles within-wallet transfers (same-wallet, same-account coin moves). `shouldTransferInsteadOfSwap(from, to)` detects same-wallet same-currency pairs. `updateTransfer(from, to, amount)` returns a `SwapState.Transfer` (not a quote). No fee calculation involved.
Files:
- `features/swap/domain/src/main/java/com/tangem/feature/swap/domain/transfer/SwapTransferInteractor.kt`
- `features/swap/domain/src/main/java/com/tangem/feature/swap/domain/transfer/SwapTransferInteractorImpl.kt`
## Fee Architecture (post [REDACTED_TASK_KEY] refactor)
The fee subsystem was fully redesigned across three tickets ([REDACTED_TASK_KEY], [REDACTED_TASK_KEY], [REDACTED_TASK_KEY], [REDACTED_TASK_KEY]). All legacy `loadFeeForSwapTransaction`, `loadFeeForDex`, `getFeeForCex` overloads have been **removed**. The current design:
### Class Hierarchy
```
SwapInteractor.loadSwapFee() ← unified entry point (Phase 3)
├─ DEX/DEX_BRIDGE → DexSwapFeeCalculator.calculate() → DexFeeResult
│ ├─ Solana path: TransactionData.Compiled (no gas bump)
│ └─ EVM path: TransactionData.Uncompiled + patchEthGasLimitForSwap(DEX_PERCENTAGE=112)
│ └─ fallback: GetEthSpecificFeeUseCase on IllegalStateException
└─ CEX → CexSwapFeeCalculator.calculate() → CexFeeResult
├─ selectedFeeToken == null → EstimateFeeForGaslessTxUseCase (no gas bump)
├─ selectedFeeToken is Token → EstimateFeeForTokenUseCase (no gas bump)
└─ selectedFeeToken is Coin → EstimateFeeUseCase + patchEthGasLimitForSwap(SEND_PERCENTAGE=105)
SwapFeeFactory.from(transactionFeeResult, selectedFeeToken, otherNativeFee, feeBucket)
→ SwapFee (the single fee carrier used everywhere downstream)
SwapInteractor.applySwapFee(state, fee) ← patches QuotesLoadedState (Phase 4)
→ recomputes balanceStatus: SwapBalanceStatus (`Pending` / `Sufficient` / `FeeAdjustedAmount` / `InsufficientAmount` / `InsufficientFee`), currencyCheck, validationResult
```
### Key Types
| Type | File | Purpose |
|------|------|---------|
| `SwapFee` | `domain/models/ui/SwapFee.kt` | Unified carrier: `fee: Fee`, `transactionFeeResult: TransactionFeeResult`, `selectedFeeToken: CryptoCurrencyStatus`, `otherNativeFee: BigDecimal`, `feeBucket: FeeBucket` |
| `FeeBucket` | `domain/models/ui/FeeBucket.kt` | `SLOW/MARKET/FAST/SUGGESTED/CUSTOM`; `toAnalyticsName()` replaces legacy `FeeType.getNameForAnalytics()` |
| `TransactionFeeResult` | `domain/fee/TransactionFeeResult.kt` | Sealed: `Loaded(TransactionFee)` for native, `LoadedExtended(TransactionFeeExtended)` for gasless/token |
| `DexFeeResult` | `domain/fee/DexFeeResult.kt` | `transactionFee`, `otherNativeFee`, `gas: BigInteger?` |
| `CexFeeResult` | `domain/fee/CexFeeResult.kt` | `transactionFee: TransactionFeeResult` |
| `DexSwapFeeCalculator` | `domain/fee/DexSwapFeeCalculator.kt` | Solana vs EVM branching, 12% gas bump |
| `CexSwapFeeCalculator` | `domain/fee/CexSwapFeeCalculator.kt` | gasless/token/native branching, 5% gas bump |
| `SwapFeeFactory` | `domain/fee/SwapFeeFactory.kt` | `fromLoaded`, `fromLoadedExtended`, `from` (polymorphic) + `selectFee` for bucket picking |
| `PatchEthGasLimitForSwap` | `domain/fee/PatchEthGasLimitForSwap.kt` | Multiplies ETH gas limit. `DEX_PERCENTAGE=112`, `SEND_PERCENTAGE=105` |
### DI for Fee Classes
Two `PatchEthGasLimitForSwap` instances with `@Qualifier`:
- `@SwapDexGasLimit``DEX_PERCENTAGE=112` → injected into `DexSwapFeeCalculator`
- `@SwapSendGasLimit``SEND_PERCENTAGE=105` → injected into `CexSwapFeeCalculator`
Qualifiers: `features/swap/domain/src/main/java/com/tangem/feature/swap/domain/di/SwapFeeQualifiers.kt`
Bindings: `features/swap/domain/src/main/java/com/tangem/feature/swap/domain/di/SwapDomainModule.kt`
### Fee Selector Wiring (SwapModel.FeeSelectorRepository)
`SwapModel` contains an inner class `FeeSelectorRepository` that implements `SwapFeeSelectorBlockComponent.ModelRepositoryExtended`. This is the bridge between the send-v2 fee selector UI component and the swap domain:
- `loadFeeExtended(selectedToken)` → calls `swapInteractor.loadSwapFee(...)`, wraps result as `TransactionFeeExtended` for the fee selector block
- `loadFee()` → same path, extracts `TransactionFee` from the `SwapFee` result
- `onResult(newState: FeeSelectorUM)` → when fee selector emits `Content`, calls `swapInteractor.applySwapFee(currentQuotesLoadedState, swapFee)` and updates `dataState.lastLoadedSwapStates`
DEX path requires a pre-fetched `swapDataModel` (populated by `loadDexSwapDataNoFee`). CEX passes `swapData = null`.
`FeeItem``FeeBucket` mapping lives at `SwapModel.FeeItem.toFeeBucket()` (line ~1921).
`getSelectedSwapFee()` (line ~1882) — reconstructs a `SwapFee` from `feeSelectorRepository.state.value as FeeSelectorUM.Content`.
### otherNativeFee (DEX bridge)
`ExpressTransactionModel.DEX.otherNativeFeeWei` — present only for `DEX_BRIDGE` providers. Converted from Wei in `DexSwapFeeCalculator.calculate()` and propagated as `DexFeeResult.otherNativeFee`. Carried through to `SwapFee.otherNativeFee`.
`applySwapFee` uses `fee.fee.amount.value + fee.otherNativeFee` as the balance check amount. `resolveOtherNativeFee()` in `SwapModel` reads it from `dataState.swapDataModel.transaction` since `FeeSelectorUM` does not carry it.
## Key Domain Models
- `SwapState` (sealed) — `QuotesLoadedState`, `Transfer`, `EmptyAmountState`, `SwapError`
- `QuotesLoadedState` carries `preparedSwapConfigState: PreparedSwapConfigState` (balance checks, fee state, includeFeeInAmount), `permissionState`, `swapDataModel`, `currencyCheck`, `validationResult`, `minAdaValue`, `swapProvider`
- `SwapProvider``providerId`, `name`, `type: ExchangeProviderType` (DEX/CEX/DEX_BRIDGE), rates, slippage, TOS links
- `SwapPairLeast` — from/to `LeastTokenInfo` (contractAddress + networkId) + `providers: List<SwapProvider>`
- `SwapDataModel` — quote result with `transaction: ExpressTransactionModel` (sealed: `DEX`, `CEX`)
- `SwapAmount``value: BigDecimal` + `decimals: Int`
- `TokenSwapInfo``tokenAmount: SwapAmount`, `amountFiat: BigDecimal`, `swapCurrencyStatus: SwapCurrencyStatus`
File locations:
- `features/swap/domain/src/main/java/com/tangem/feature/swap/domain/models/ui/SwapState.kt`
- `features/swap/domain/src/main/java/com/tangem/feature/swap/domain/models/domain/SwapDataModel.kt`
- `features/swap/domain/src/main/java/com/tangem/feature/swap/domain/models/domain/SwapFeeState.kt`
## DI Modules
| Module | Scope | Bindings |
|--------|-------|----------|
| Module | Scope | Purpose |
|--------|-------|---------|
| `SwapFeatureModule` | Singleton | `SwapComponent.Factory`, `SwapFeatureToggles` |
| `SwapModelModule` | ModelComponent | `SwapModel` into model map |
| `SwapEntryModule` | Singleton + Model | `SwapEntryComponent.Factory`, `SwapEntryModel` |
| `ChooseTokenModule` | Singleton + Model | `ChooseTokenComponent.Factory`, `ChooseTokenBridge.Factory`, `ChooseTokenModel` |
| `SwapSingletonModule` | Singleton | `AmountFormatter` |
| `SwapDomainModule` | Singleton | `DexSwapFeeCalculator`, `CexSwapFeeCalculator`, two `PatchEthGasLimitForSwap` instances with qualifiers |
| `SwapDomainBindModule` | Singleton | `SwapInteractor``SwapInteractorImpl`, `SwapTransferInteractor``SwapTransferInteractorImpl` |
## UI Layer
## Analytics
- `SwapScreen` — main swap composable (send card, receive card, swap button, provider, notifications, fee)
- `SwapSuccessScreen` — post-swap success with transaction details
- `SwapScreenContent` — layout with `ConstraintLayout` for card positioning
- `TransactionCard` / `TransactionCardEmpty` — token cards with amount input
- Token cards pass `TokenSelectionDirection.FROM` / `.TO` to `onSelectTokenClick`
`SwapEvents` sealed class hierarchy at `features/swap/impl/src/main/java/com/tangem/feature/swap/analytics/SwapEvents.kt`.
Fee tier analytics: `FeeBucket.toAnalyticsName()``"Min"/"Normal"/"Max"/"Suggested"/"Custom"`. Maps to `AnalyticsParam.FeeType.fromString(feeBucket.toAnalyticsName())`. The legacy `FeeType.getNameForAnalytics()` extension was removed in Phase 5 of the fee redesign.
## Navigation Summary
@ -138,11 +266,49 @@ AppRouter (global)
└─ SwapFeeSelectorBlockComponent (inline fee block)
```
## Build Commands
## UI Layer
```bash
./gradlew :features:swap:impl:compileDebugKotlin
./gradlew :features:swap:api:compileDebugKotlin
./gradlew :features:swap:domain:compileDebugKotlin
./gradlew :features:swap:impl:detekt
```
- `SwapScreen` — main swap composable (send card, receive card, swap button, provider, notifications, fee)
- `SwapSuccessScreen` — post-swap success with transaction details
- `SwapScreenContent` — layout with `ConstraintLayout` for card positioning
- `TransactionCard` / `TransactionCardEmpty` — token cards with amount input
- Token cards pass `TokenSelectionDirection.FROM` / `.TO` to `onSelectTokenClick`
Files: `features/swap/impl/src/main/java/com/tangem/feature/swap/ui/`
## Testing
All domain-layer tests use JUnit 5 + MockK + Truth. Base class `SwapInteractorImplTestBase` wires all ~30 `SwapInteractorImpl` dependencies as relaxed mocks and exposes `sut: SwapInteractorImpl` via `lazy`. Tests extend it and stub only what they need.
Test files by topic:
- `SwapInteractorImplTestBase.kt` — base class; also contains `buildSwapCurrencyStatus(...)` and other builders
- `SwapInteractorImplLoadSwapFeeTest.kt` — unified `loadSwapFee` (all strategy branches: DEX-EVM, DEX-Solana, DEX bridge, CEX gasless-native, CEX gasless-token, CEX explicit-token, null swapData, zero amount)
- `SwapInteractorImplApplySwapFeeTest.kt``applySwapFee` balance/fee-state patching
- `SwapInteractorImplFindBestQuoteTest.kt` — provider dispatch, balance checks
- `SwapInteractorImplLoadDexSwapDataNoFeeTest.kt` — DEX quote-load without fee
- `fee/DexSwapFeeCalculatorTest.kt` — DEX calculator (Solana, EVM, gas fallback, bridge fee)
- `fee/CexSwapFeeCalculatorTest.kt` — CEX calculator (gasless, token, native)
- `fee/SwapFeeFactoryTest.kt``SwapFeeFactory` bucket selection
- `fee/PatchEthGasLimitForSwapTest.kt` — gas limit bump math
- `transfer/SwapTransferInteractorImplTest.kt` — transfer detection and state building
- `impl/StateBuilderInitialStateTest.kt`, `StateBuilderPairsTest.kt` — UI state construction
## Gotchas
**Fee state is transient on DEX.** `loadDexSwapDataNoFee` returns a `QuotesLoadedState` with `feeState = NotEnough()` and `isBalanceEnough = false`. The real values are only set after the fee selector resolves and calls `applySwapFee`. Do not check `preparedSwapConfigState.isBalanceEnough` before the fee selector has emitted a `FeeSelectorUM.Content` state.
**`SwapFee` is not carried in `SwapProcessDataState`.** It is reconstructed from `feeSelectorRepository.state.value` via `getSelectedSwapFee()` at each call site (swap execution, analytics). `otherNativeFee` must be re-read from `dataState.swapDataModel.transaction` because `FeeSelectorUM` does not carry it.
**DEX requires pre-fetched `swapDataModel`.** `FeeSelectorRepository.loadFeeExtended` returns `Left(UnknownError)` when `dataState.swapDataModel == null`. This is by design: `manageDex` only calls `loadDexSwapDataNoFee` (which populates `swapDataModel`) when allowance is OK and balance is sufficient. If the user has insufficient balance or a pending approval, the fee selector will not load.
**`PatchEthGasLimitForSwap` has two instances with different percentages.** DEX uses 12%, CEX uses 5%. They are distinguished by `@SwapDexGasLimit` and `@SwapSendGasLimit` qualifiers. Passing the wrong qualifier to a calculator is a silent bug with no compile-time check.
**`Fee.Ethereum.TokenCurrency` throws.** `PatchEthGasLimitForSwap.increaseEthGasLimitInNeeded` calls `error("handle in [REDACTED_TASK_KEY]")` for `TokenCurrency`. This path must not be reached in production. The issue is tracked but not yet resolved.
**Solana DEX fee is not patched.** Unlike EVM, `DexSwapFeeCalculator` skips `patchEthGasLimitForSwap` for Solana paths. Also: if the compiled transaction exceeds `SOLANA_TRANSACTION_SIZE_THRESHOLD_BYTES` and the wallet is `UserWallet.Cold`, the calculator raises `ExpressDataError.TooLargeSolanaTransactionError`.
**`TransactionFeeResult` sealed class is not a data class.** `Loaded(val fee: TransactionFee)` and `LoadedExtended(val fee: TransactionFeeExtended)` use regular `class`, so structural equality does not hold. Use `is`-checks and field comparison in tests.
**`SwapInteractor` interface vs `SwapInteractorImpl`.** The interface exposes `loadSwapFee` and `applySwapFee` (the new unified API). The old `loadFeeForSwapTransaction` overloads (two overloads) and `loadFeeForDex` private method have been fully removed. Do not reference them in new code or tests.
**Transfer mode vs swap mode.** `SwapTransferInteractor.shouldTransferInsteadOfSwap` detects same-wallet same-currency pairs and returns `true`, causing the UI to show `SwapState.Transfer` instead of `SwapState.QuotesLoadedState`. No fee selector is shown in transfer mode.

View file

@ -347,7 +347,7 @@ internal class DefaultSwapRepository(
).getOrThrow()
if (dataSignatureVerifier.verifySignature(response.signature, response.txDetailsJson)) {
val txDetails = parseTxDetails(response.txDetailsJson)
?: return@withContext ExpressDataError.UnknownError.left()
?: return@withContext ExpressDataError.UnknownError().left()
if (txDetails.requestId != requestId) {
return@withContext ExpressDataError.InvalidRequestIdError().left()
}
@ -413,7 +413,7 @@ internal class DefaultSwapRepository(
return if (ex is ApiResponseError.HttpException) {
errorsDataConverter.convert(ex.errorBody.orEmpty())
} else {
ExpressDataError.UnknownError
ExpressDataError.UnknownError()
}
}

View file

@ -14,7 +14,7 @@ internal class ErrorsDataConverter(
@Suppress("MagicNumber", "CyclomaticComplexMethod")
override fun convert(value: String): ExpressDataError {
try {
val error = jsonAdapter.fromJson(value)?.error ?: return ExpressDataError.UnknownError
val error = jsonAdapter.fromJson(value)?.error ?: return ExpressDataError.UnknownError()
return when (error.code) {
2010 -> ExpressDataError.BadRequest(code = error.code)
@ -34,7 +34,7 @@ internal class ErrorsDataConverter(
else -> ExpressDataError.UnknownErrorWithCode(error.code)
}
} catch (e: Exception) {
return ExpressDataError.UnknownError
return ExpressDataError.UnknownError()
}
}

View file

@ -1,19 +1,16 @@
package com.tangem.feature.swap.domain
import arrow.core.Either
import com.tangem.blockchain.common.transaction.TransactionFee
import com.tangem.domain.express.models.ExpressError
import com.tangem.domain.express.models.ExpressOperationType
import com.tangem.domain.models.currency.CryptoCurrency
import com.tangem.domain.models.currency.CryptoCurrencyStatus
import com.tangem.domain.swap.models.SwapCurrencyStatus
import com.tangem.domain.transaction.error.GetFeeError
import com.tangem.domain.transaction.models.TransactionFeeExtended
import com.tangem.feature.swap.domain.models.SwapAmount
import com.tangem.feature.swap.domain.models.domain.*
import com.tangem.feature.swap.domain.models.ui.SwapFee
import com.tangem.feature.swap.domain.models.ui.SwapState
import com.tangem.feature.swap.domain.models.ui.SwapTransactionState
import com.tangem.feature.swap.domain.models.ui.TxFee
import java.math.BigDecimal
interface SwapInteractor {
@ -36,7 +33,6 @@ interface SwapInteractor {
pairs: List<SwapPairLeast>,
): List<SwapProvider>
@Suppress("LongParameterList")
@Throws(IllegalStateException::class)
suspend fun findBestQuote(
fromSwapCurrencyStatus: SwapCurrencyStatus,
@ -44,9 +40,19 @@ interface SwapInteractor {
providers: List<SwapProvider>,
amountToSwap: String,
reduceBalanceBy: BigDecimal,
txFeeSealedState: TxFeeSealedState,
): Map<SwapProvider, SwapState>
/**
* Branch selection:
* - CEX, native fee `sendTransactionUseCase`
* - CEX, gasless / token fee (`fee.transactionFeeResult is LoadedExtended` and
* `fee.selectedFeeToken.currency is CryptoCurrency.Token`) `createAndSendGaslessTransactionUseCase`
* - DEX (Solana) compiled tx signed as-is. `fee` is carried for analytics / UI only.
*
* @param fee the user-selected fee for the transaction. Required for DEX (non-Solana) and CEX;
* may be `null` for Solana DEX and the Tangem Pay withdrawal short-circuit.
*/
@Suppress("LongParameterList")
@Throws(IllegalStateException::class)
suspend fun onSwap(
@ -55,12 +61,25 @@ interface SwapInteractor {
swapProvider: SwapProvider,
swapData: SwapDataModel?,
amountToSwap: String,
includeFeeInAmount: IncludeFeeInAmount,
fee: TxFee?,
balanceStatus: SwapBalanceStatus,
fee: SwapFee?,
expressOperationType: ExpressOperationType,
isTangemPayWithdrawal: Boolean,
): SwapTransactionState
/**
* Patches an existing [SwapState.QuotesLoadedState] with a freshly resolved [SwapFee] without re-fetching quotes.
*
* Recomputes `preparedSwapConfigState.balanceStatus`, plus `currencyCheck` and `validationResult`.
*
* **Idempotent**: applying the same [SwapFee] twice yields an equal state.
*/
suspend fun applySwapFee(
state: SwapState.QuotesLoadedState,
fee: SwapFee,
lastReducedBalanceBy: BigDecimal,
): SwapState.QuotesLoadedState
/**
* Returns token in wallet balance
*
@ -68,8 +87,6 @@ interface SwapInteractor {
*/
fun getTokenBalance(token: CryptoCurrencyStatus): SwapAmount
suspend fun getNativeToken(swapCurrencyStatus: SwapCurrencyStatus): CryptoCurrency
@Suppress("LongParameterList")
suspend fun storeSwapTransaction(
fromSwapCurrencyStatus: SwapCurrencyStatus,
@ -83,19 +100,34 @@ interface SwapInteractor {
averageDuration: Int? = null,
)
suspend fun loadFeeForSwapTransaction(
fromSwapCurrencyStatus: SwapCurrencyStatus,
amount: String,
reduceBalanceBy: BigDecimal,
/**
* Unified swap-fee entry point. Single fee load API used by all providers types (DEX, DEX_BRIDGE, CEX).
*
* Delegates to `DexSwapFeeCalculator` for DEX/DEX_BRIDGE or to `CexSwapFeeCalculator` for CEX,
* then wraps the result in a [SwapFee].
*
* The DEX path consumes the pre-fetched [swapData] (which carries the `ExpressTransactionModel.DEX` payload);
* the CEX path computes the fee directly from `amount`.
* When [swapData] is `null` on the DEX path the call short-circuits to `Left(GetFeeError.UnknownError)`
* callers must ensure swap data has resolved before triggering fee load.
*
* Native-fallback semantics on the CEX gasless path are preserved: when
* [selectedFeeToken] is `null`, `EstimateFeeForGaslessTxUseCase` is invoked and chooses
* native vs token internally. The returned `SwapFee.selectedFeeToken` is non-null
* resolved from gasless's chosen token or from the native coin status when gasless picked
* native.
*
* @param swapData pre-fetched DEX exchange data; pass `null` for CEX providers.
* @param selectedFeeToken the currency the user picked to pay the fee. `null` triggers the
* gasless / native-default path on CEX.
*/
@Suppress("LongParameterList")
suspend fun loadSwapFee(
provider: SwapProvider,
fromStatus: SwapCurrencyStatus,
toStatus: SwapCurrencyStatus,
amount: SwapAmount,
swapData: SwapDataModel?,
selectedFeeToken: CryptoCurrencyStatus?,
): Either<GetFeeError, TransactionFeeExtended>
suspend fun loadFeeForSwapTransaction(
fromSwapCurrencyStatus: SwapCurrencyStatus,
toSwapCurrencyStatus: SwapCurrencyStatus,
amount: String,
reduceBalanceBy: BigDecimal,
provider: SwapProvider,
): Either<GetFeeError, TransactionFee>
): Either<GetFeeError, SwapFee>
}

View file

@ -1,5 +1,9 @@
package com.tangem.feature.swap.domain.di
import com.tangem.domain.transaction.usecase.CreateTransactionDataExtrasUseCase
import com.tangem.domain.transaction.usecase.EstimateFeeUseCase
import com.tangem.domain.transaction.usecase.GetEthSpecificFeeUseCase
import com.tangem.domain.transaction.usecase.GetFeeUseCase
import com.tangem.core.abtests.manager.ABTestsManager
import com.tangem.feature.swap.domain.AllowPermissionsHandler
import com.tangem.feature.swap.domain.AllowPermissionsHandlerImpl
@ -8,8 +12,17 @@ import com.tangem.feature.swap.domain.SetSwapUiModeUseCase
import com.tangem.feature.swap.domain.SwapFeedbackUseCase
import com.tangem.feature.swap.domain.SwapInteractor
import com.tangem.feature.swap.domain.SwapInteractorImpl
import com.tangem.domain.transaction.usecase.*
import com.tangem.domain.transaction.usecase.gasless.EstimateFeeForGaslessTxUseCase
import com.tangem.domain.transaction.usecase.gasless.EstimateFeeForTokenUseCase
import com.tangem.domain.transaction.usecase.gasless.GetFeeForTokenUseCase
import com.tangem.domain.walletmanager.WalletManagersFacade
import com.tangem.feature.swap.domain.*
import com.tangem.feature.swap.domain.api.SwapFeedbackRepository
import com.tangem.feature.swap.domain.api.SwapRepository
import com.tangem.feature.swap.domain.fee.CexSwapFeeCalculator
import com.tangem.feature.swap.domain.fee.DexSwapFeeCalculator
import com.tangem.feature.swap.domain.fee.PatchEthGasLimitForSwap
import com.tangem.features.swap.SwapFeatureToggles
import com.tangem.feature.swap.domain.transfer.SwapTransferInteractor
import com.tangem.feature.swap.domain.transfer.SwapTransferInteractorImpl
@ -47,6 +60,52 @@ internal class SwapDomainModule {
fun provideSetSwapUiModeUseCase(swapRepository: SwapRepository): SetSwapUiModeUseCase =
SetSwapUiModeUseCase(swapRepository = swapRepository)
@Provides
@Singleton
@SwapDexGasLimit
fun provideDexPatchEthGasLimitForSwap(): PatchEthGasLimitForSwap {
return PatchEthGasLimitForSwap(percentage = PatchEthGasLimitForSwap.DEX_PERCENTAGE)
}
@Provides
@Singleton
@SwapSendGasLimit
fun provideSendPatchEthGasLimitForSwap(): PatchEthGasLimitForSwap {
return PatchEthGasLimitForSwap(percentage = PatchEthGasLimitForSwap.SEND_PERCENTAGE)
}
@Provides
@Singleton
fun provideDexSwapFeeCalculator(
getFeeUseCase: GetFeeUseCase,
getEthSpecificFeeUseCase: GetEthSpecificFeeUseCase,
getFeeForTokenUseCase: GetFeeForTokenUseCase,
createTransactionExtrasUseCase: CreateTransactionDataExtrasUseCase,
walletManagersFacade: WalletManagersFacade,
@SwapDexGasLimit patchEthGasLimitForSwap: PatchEthGasLimitForSwap,
): DexSwapFeeCalculator = DexSwapFeeCalculator(
getFeeUseCase = getFeeUseCase,
getEthSpecificFeeUseCase = getEthSpecificFeeUseCase,
getFeeForTokenUseCase = getFeeForTokenUseCase,
createTransactionExtrasUseCase = createTransactionExtrasUseCase,
walletManagersFacade = walletManagersFacade,
patchEthGasLimitForSwap = patchEthGasLimitForSwap,
)
@Provides
@Singleton
fun provideCexSwapFeeCalculator(
estimateFeeUseCase: EstimateFeeUseCase,
estimateFeeForTokenUseCase: EstimateFeeForTokenUseCase,
estimateFeeForGaslessTxUseCase: EstimateFeeForGaslessTxUseCase,
@SwapSendGasLimit patchEthGasLimitForSwap: PatchEthGasLimitForSwap,
): CexSwapFeeCalculator = CexSwapFeeCalculator(
estimateFeeUseCase = estimateFeeUseCase,
estimateFeeForTokenUseCase = estimateFeeForTokenUseCase,
estimateFeeForGaslessTxUseCase = estimateFeeForGaslessTxUseCase,
patchEthGasLimitForSwap = patchEthGasLimitForSwap,
)
@Provides
@Singleton
fun provideSwapFeedbackUseCase(repository: SwapFeedbackRepository): SwapFeedbackUseCase {

View file

@ -0,0 +1,27 @@
@file:Suppress("Filename")
package com.tangem.feature.swap.domain.di
import javax.inject.Qualifier
/**
* Qualifier for the DEX-flavoured `PatchEthGasLimitForSwap` (12% gas-limit bump).
*
* For DEX, the gas limit calculated by the DEX provider for a given payload may shift during
* mining; providers recommend padding the limit a bit so the transaction completes.
*/
@Qualifier
@MustBeDocumented
@Retention(AnnotationRetention.RUNTIME)
annotation class SwapDexGasLimit
/**
* Qualifier for the send/CEX-flavoured `PatchEthGasLimitForSwap` (5% gas-limit bump).
*
* For CEX, the fee is calculated for a randomly generated address and the gas limit may differ
* for the actual destination. Padding the limit slightly avoids underpaid transactions.
*/
@Qualifier
@MustBeDocumented
@Retention(AnnotationRetention.RUNTIME)
annotation class SwapSendGasLimit

View file

@ -0,0 +1,16 @@
package com.tangem.feature.swap.domain.fee
/**
* Result of calculating the CEX swap transaction fee.
*
* [REDACTED_TASK_KEY] produced by `CexSwapFeeCalculator`. Mirrors the data points that the CEX path of
* `SwapInteractorImpl.loadFeeForSwapTransaction` (overload 2) and `getFeeForCex` compute today.
*
* @param transactionFee the patched fee. For EVM the 5% gas-limit bump from
* `PatchEthGasLimitForSwap.SEND_PERCENTAGE` has already been applied. The variant
* [TransactionFeeResult.Loaded] vs [TransactionFeeResult.LoadedExtended] depends on the
* selected fee strategy: native fee `Loaded`; gasless / explicit token `LoadedExtended`.
*/
data class CexFeeResult(
val transactionFee: TransactionFeeResult,
)

View file

@ -0,0 +1,86 @@
package com.tangem.feature.swap.domain.fee
import arrow.core.Either
import arrow.core.raise.either
import com.tangem.domain.models.currency.CryptoCurrency
import com.tangem.domain.models.currency.CryptoCurrencyStatus
import com.tangem.domain.models.wallet.UserWallet
import com.tangem.domain.swap.models.SwapCurrencyStatus
import com.tangem.domain.transaction.error.GetFeeError
import com.tangem.domain.transaction.usecase.EstimateFeeUseCase
import com.tangem.domain.transaction.usecase.gasless.EstimateFeeForGaslessTxUseCase
import com.tangem.domain.transaction.usecase.gasless.EstimateFeeForTokenUseCase
import java.math.BigDecimal
/**
* Calculates the transaction fee for a CEX swap.
*
* [REDACTED_TASK_KEY] combines the two existing CEX fee paths in `SwapInteractorImpl` into one place:
* - `loadFeeForSwapTransaction` overload 2 (CEX branch, native fee via [EstimateFeeUseCase])
* - `loadFeeForSwapTransaction` overload 1 (token/gasless fee via [EstimateFeeForTokenUseCase] or
* [EstimateFeeForGaslessTxUseCase])
*
* Strategy is selected by [selectedFeeToken]:
* - `null` gasless. Calls [EstimateFeeForGaslessTxUseCase] which itself decides whether to use
* a native or token fee. **No gas-limit bump is applied** here, matching production behavior of
* overload 1.
* - non-null + token currency calls [EstimateFeeForTokenUseCase]. **No gas-limit bump.**
* - non-null + native (coin) currency calls [EstimateFeeUseCase]. **The 5% gas-limit bump is
* applied via [patchEthGasLimitForSwap]** for parity with `loadFeeForSwapTransaction` overload 2.
* The bump is a no-op for non-Ethereum fees, so this is safe across chains.
*
* Behavior is byte-for-byte identical to the original methods in `SwapInteractorImpl`. The
* original code is intentionally retained alongside this calculator until the caller is migrated
* to delegate to it (the migration is deferred see plan).
*/
class CexSwapFeeCalculator(
private val estimateFeeUseCase: EstimateFeeUseCase,
private val estimateFeeForTokenUseCase: EstimateFeeForTokenUseCase,
private val estimateFeeForGaslessTxUseCase: EstimateFeeForGaslessTxUseCase,
private val patchEthGasLimitForSwap: PatchEthGasLimitForSwap,
) {
suspend fun calculate(
userWallet: UserWallet,
fromSwapCurrencyStatus: SwapCurrencyStatus,
amount: BigDecimal,
selectedFeeToken: CryptoCurrencyStatus?,
): Either<GetFeeError, CexFeeResult> = either {
if (amount.signum() == 0) {
raise(GetFeeError.UnknownError)
}
val transactionFeeResult: TransactionFeeResult = when {
selectedFeeToken == null -> {
// Gasless path — overload 1 in SwapInteractorImpl. No gas-limit bump.
val feeExtended = estimateFeeForGaslessTxUseCase(
amount = amount,
userWallet = userWallet,
sendingTokenCurrencyStatus = fromSwapCurrencyStatus.status,
).bind()
TransactionFeeResult.LoadedExtended(feeExtended)
}
selectedFeeToken.currency is CryptoCurrency.Token -> {
// Explicit gasless-token path — overload 1 in SwapInteractorImpl. No gas-limit bump.
val feeExtended = estimateFeeForTokenUseCase(
userWallet = userWallet,
feeTokenCurrencyStatus = selectedFeeToken,
sendingTokenCurrencyStatus = fromSwapCurrencyStatus.status,
amount = amount,
).bind()
TransactionFeeResult.LoadedExtended(feeExtended)
}
else -> {
// Explicit native fee path — overload 2 in SwapInteractorImpl. Apply 5% bump.
val fee = estimateFeeUseCase(
amount = amount,
userWallet = userWallet,
cryptoCurrencyStatus = fromSwapCurrencyStatus.status,
).bind()
TransactionFeeResult.Loaded(patchEthGasLimitForSwap(fee))
}
}
CexFeeResult(transactionFee = transactionFeeResult)
}
}

View file

@ -0,0 +1,28 @@
package com.tangem.feature.swap.domain.fee
import java.math.BigDecimal
import java.math.BigInteger
/**
* Result of calculating the DEX swap transaction fee.
*
* [REDACTED_TASK_KEY] produced by `DexSwapFeeCalculator`. Mirrors the data points that
* `SwapInteractorImpl.loadFeeForDex` + `getFeeDataForDexSwap` + `getFeeDataForSolanaDexSwap`
* compute today, but exposes them as a single value type instead of leaking through several
* private return types.
*
* @param transactionFee the fee already patched by `PatchEthGasLimitForSwap` for EVM DEX paths;
* raw fee for Solana (no gas-limit bump applies). Solana always returns [TransactionFeeResult.Loaded];
* EVM may return [TransactionFeeResult.Loaded] or [TransactionFeeResult.LoadedExtended] depending
* on whether a `selectedToken` is supplied (token = LoadedExtended).
* @param otherNativeFee the bridge protocol fee carried by the express transaction model
* (`ExpressTransactionModel.DEX.otherNativeFeeWei` shifted left by the native coin's decimals).
* Zero unless the provider is `DEX_BRIDGE`.
* @param gas the gas value from `ExpressTransactionModel.DEX.gas`, propagated for callers that
* need to construct the transaction extras downstream. `null` for non-EVM (Solana) paths.
*/
data class DexFeeResult(
val transactionFee: TransactionFeeResult,
val otherNativeFee: BigDecimal,
val gas: BigInteger?,
)

View file

@ -0,0 +1,216 @@
package com.tangem.feature.swap.domain.fee
import android.util.Base64
import arrow.core.Either
import arrow.core.raise.either
import com.tangem.blockchain.blockchains.solana.SolanaTransactionHelper
import com.tangem.blockchain.common.Amount
import com.tangem.blockchain.common.Blockchain
import com.tangem.blockchain.common.TransactionData
import com.tangem.blockchain.common.transaction.TransactionFee
import com.tangem.blockchainsdk.utils.fromNetworkId
import com.tangem.domain.models.currency.CryptoCurrency
import com.tangem.domain.models.currency.CryptoCurrencyStatus
import com.tangem.domain.models.network.Network
import com.tangem.domain.models.wallet.UserWallet
import com.tangem.domain.swap.models.SwapCurrencyStatus
import com.tangem.domain.transaction.usecase.CreateTransactionDataExtrasUseCase
import com.tangem.domain.transaction.usecase.GetEthSpecificFeeUseCase
import com.tangem.domain.transaction.usecase.GetFeeUseCase
import com.tangem.domain.transaction.usecase.gasless.GetFeeForTokenUseCase
import com.tangem.domain.walletmanager.WalletManagersFacade
import com.tangem.feature.swap.domain.models.ExpressDataError
import com.tangem.feature.swap.domain.models.domain.ExpressTransactionModel
import com.tangem.lib.crypto.BlockchainUtils.SOLANA_TRANSACTION_SIZE_THRESHOLD_BYTES
import com.tangem.lib.crypto.BlockchainUtils.isSolana
import com.tangem.utils.logging.TangemLogger
import java.math.BigDecimal
/**
* Calculates the on-chain transaction fee for a DEX swap.
*
* [REDACTED_TASK_KEY] extracted verbatim from `SwapInteractorImpl.loadFeeForDex`,
* `getFeeDataForDexSwap` and `getFeeDataForSolanaDexSwap` so the DEX-fee strategy is testable in
* isolation. The original methods are intentionally retained in `SwapInteractorImpl` until the
* caller is migrated to delegate to this calculator (the migration is deferred see plan).
*
* Strategy selection mirrors the source: Solana uses [TransactionData.Compiled] from the
* Express-supplied `txData` and skips the gas patch; everything else uses
* [TransactionData.Uncompiled] and applies the 12% gas-limit bump via [patchEthGasLimitForSwap].
*
* If [GetFeeUseCase] throws `IllegalStateException` (e.g. payload too large to estimate), the
* calculator falls back to [GetEthSpecificFeeUseCase] using the gas value carried by the Express
* transaction model same as the production path.
*
* @see DexFeeResult for the returned shape.
*/
@Suppress("LongParameterList")
class DexSwapFeeCalculator(
private val getFeeUseCase: GetFeeUseCase,
private val getEthSpecificFeeUseCase: GetEthSpecificFeeUseCase,
private val getFeeForTokenUseCase: GetFeeForTokenUseCase,
private val createTransactionExtrasUseCase: CreateTransactionDataExtrasUseCase,
private val walletManagersFacade: WalletManagersFacade,
private val patchEthGasLimitForSwap: PatchEthGasLimitForSwap,
) {
suspend fun calculate(
fromSwapCurrencyStatus: SwapCurrencyStatus,
transaction: ExpressTransactionModel.DEX,
selectedToken: CryptoCurrencyStatus? = null,
): Either<ExpressDataError, DexFeeResult> = either {
val networkRawId = fromSwapCurrencyStatus.currency.network.rawId
val nativeCoinDecimals = Blockchain.fromNetworkId(networkRawId)?.decimals()
?: error("Blockchain not found")
val otherNativeFee = transaction.otherNativeFeeWei
?.movePointLeft(nativeCoinDecimals)
?: BigDecimal.ZERO
if (isSolana(networkRawId)) {
val transactionBytes = Base64.decode(transaction.txData, Base64.NO_WRAP)
val formattedHash = getFormattedHash(transactionBytes)
// TODO Update after new firmware [REDACTED_JIRA]
if (formattedHash.size > SOLANA_TRANSACTION_SIZE_THRESHOLD_BYTES &&
fromSwapCurrencyStatus.userWallet is UserWallet.Cold
) {
raise(ExpressDataError.TooLargeSolanaTransactionError())
}
val solanaFee = getFeeDataForSolanaDexSwap(
fromSwapCurrencyStatus = fromSwapCurrencyStatus,
transactionBytes = transactionBytes,
)
DexFeeResult(
transactionFee = TransactionFeeResult.Loaded(solanaFee),
otherNativeFee = otherNativeFee,
gas = null,
)
} else {
val rawFeeResult = getFeeDataForDexSwap(
fromSwapCurrencyStatus = fromSwapCurrencyStatus,
transaction = transaction,
selectedToken = selectedToken,
).bind()
// Apply the 12% bump on EVM, mirroring SwapInteractorImpl.loadFeeForDex.
// The original cast `(fee as TransactionFeeResult.Loaded)` only holds when
// selectedToken == null; we defensively support LoadedExtended too so the calculator
// also handles the gasless-token DEX branch (currently unreachable from production
// callers, kept for symmetry with the CEX calculator).
val patched: TransactionFeeResult = when (rawFeeResult) {
is TransactionFeeResult.Loaded ->
TransactionFeeResult.Loaded(patchEthGasLimitForSwap(rawFeeResult.fee))
is TransactionFeeResult.LoadedExtended ->
TransactionFeeResult.LoadedExtended(
rawFeeResult.fee.copy(
transactionFee = patchEthGasLimitForSwap(rawFeeResult.fee.transactionFee),
),
)
}
DexFeeResult(
transactionFee = patched,
otherNativeFee = otherNativeFee,
gas = transaction.gas,
)
}
}
@Suppress("CyclomaticComplexMethod")
private suspend fun getFeeDataForDexSwap(
fromSwapCurrencyStatus: SwapCurrencyStatus,
transaction: ExpressTransactionModel.DEX,
selectedToken: CryptoCurrencyStatus?,
): Either<ExpressDataError, TransactionFeeResult> = either {
val nativeBalance = walletManagersFacade.getNativeTokenBalance(
userWalletId = fromSwapCurrencyStatus.userWalletId,
networkId = fromSwapCurrencyStatus.currency.network.rawId,
derivationPath = fromSwapCurrencyStatus.currency.network.derivationPath.value,
)
// if native balance is zero - we can't calculate fee
if (nativeBalance.signum() == 0) {
raise(ExpressDataError.UnknownError())
}
try {
val txAmountValue = transaction.txValue ?: error("unable to get txValue")
val amountToSend = createNativeAmountForDex(txAmountValue, fromSwapCurrencyStatus.currency.network)
// transaction.txValue is always native coin
if (nativeBalance < amountToSend.value) {
error("It's impossible to calculate fee for nativeBalance.value < amountToSend.value")
}
val extras = createTransactionExtrasUseCase(
data = transaction.txData,
network = fromSwapCurrencyStatus.currency.network,
).getOrNull() ?: error("unable to create extras")
val transactionData = TransactionData.Uncompiled(
amount = amountToSend,
destinationAddress = transaction.txTo,
fee = null,
sourceAddress = transaction.txFrom,
extras = extras,
)
if (selectedToken != null && selectedToken.currency is CryptoCurrency.Token) {
getFeeForTokenUseCase(
transactionData = transactionData,
token = selectedToken.currency,
userWallet = fromSwapCurrencyStatus.userWallet,
).getOrNull()?.let { TransactionFeeResult.LoadedExtended(it) }
?: error("unable to calculate fee for token")
} else {
getFeeUseCase(
transactionData = transactionData,
network = fromSwapCurrencyStatus.currency.network,
userWallet = fromSwapCurrencyStatus.userWallet,
).getOrNull()?.let { TransactionFeeResult.Loaded(it) } ?: error("unable to calculate fee")
}
} catch (_: IllegalStateException) {
getEthSpecificFeeUseCase(
userWallet = fromSwapCurrencyStatus.userWallet,
cryptoCurrency = fromSwapCurrencyStatus.currency,
gasLimit = transaction.gas,
).getOrNull()?.let { TransactionFeeResult.Loaded(it) }
?: raise(ExpressDataError.UnknownError())
}
}
private suspend fun getFeeDataForSolanaDexSwap(
fromSwapCurrencyStatus: SwapCurrencyStatus,
transactionBytes: ByteArray,
): TransactionFee {
val transactionData = TransactionData.Compiled(
value = TransactionData.Compiled.Data.Bytes(transactionBytes),
)
return getFeeUseCase(
transactionData = transactionData,
network = fromSwapCurrencyStatus.currency.network,
userWallet = fromSwapCurrencyStatus.userWallet,
).getOrNull() ?: error("unable to calculate fee")
}
private fun createNativeAmountForDex(txValueAmount: String, network: Network): Amount {
val nativeDecimals = Blockchain.fromNetworkId(network.rawId)?.decimals()
?: error("Blockchain not found")
val decimalValue = txValueAmount.toBigDecimalOrNull()?.movePointLeft(nativeDecimals)
?: error("txValue parse error")
return Amount(
currencySymbol = network.currencySymbol,
value = decimalValue,
decimals = nativeDecimals,
)
}
// TODO create usecase [REDACTED_TASK_KEY] (parity with SwapInteractorImpl.getFormattedHash)
private fun getFormattedHash(hash: ByteArray): ByteArray {
return try {
SolanaTransactionHelper.removeSignaturesPlaceholders(hash)
} catch (e: Exception) {
TangemLogger.e("Failed to format the hash: ${e.message.orEmpty()}", e)
hash
}
}
}

View file

@ -0,0 +1,84 @@
package com.tangem.feature.swap.domain.fee
import com.tangem.blockchain.common.transaction.Fee
import com.tangem.blockchain.common.transaction.TransactionFee
import java.math.BigInteger
import java.math.RoundingMode
/**
* Increases the Ethereum gas limit on a [com.tangem.blockchain.common.transaction.TransactionFee] by the configured [percentage].
*
* [REDACTED_TASK_KEY] extracted from `SwapInteractorImpl.patchTransactionFeeForSwap` so the bump rule
* becomes a first-class, mockable, swappable use case. Two singletons are wired via DI in the
* swap module with custom `@Qualifier` annotations:
* - `@SwapDexGasLimit` [DEX_PERCENTAGE] (12% bump for DEX swap fees)
* - `@SwapSendGasLimit` [SEND_PERCENTAGE] (5% bump for CEX/send fees)
*
* Behavior is byte-for-byte identical to the original private helpers in `SwapInteractorImpl`:
* - [com.tangem.blockchain.common.transaction.Fee.Ethereum.Legacy] / [com.tangem.blockchain.common.transaction.Fee.Ethereum.EIP1559]: gasLimit *= percentage / 100, amount
* recomputed = (newGasLimit * gasPrice) shifted left by amount decimals; decimals preserved.
* - [com.tangem.blockchain.common.transaction.Fee.Ethereum.TokenCurrency]: throws `IllegalStateException("handle in [REDACTED_TASK_KEY]")`.
* - All other [com.tangem.blockchain.common.transaction.Fee] subtypes (Common, Bitcoin, Tron, etc.): returned unchanged.
*/
class PatchEthGasLimitForSwap(private val percentage: Int) {
operator fun invoke(transactionFee: TransactionFee): TransactionFee {
return when (transactionFee) {
is TransactionFee.Choosable -> transactionFee.copy(
minimum = transactionFee.minimum.increaseEthGasLimitInNeeded(percentage),
normal = transactionFee.normal.increaseEthGasLimitInNeeded(percentage),
priority = transactionFee.priority.increaseEthGasLimitInNeeded(percentage),
)
is TransactionFee.Single -> transactionFee.copy(
normal = transactionFee.normal.increaseEthGasLimitInNeeded(percentage),
)
}
}
private fun Fee.increaseEthGasLimitInNeeded(increaseBy: Int): Fee {
return when (this) {
is Fee.Ethereum.TokenCurrency -> error("handle in [REDACTED_TASK_KEY]")
is Fee.Ethereum.EIP1559,
is Fee.Ethereum.Legacy,
-> this.increaseGasLimitBy(increaseBy)
is Fee.Alephium,
is Fee.Aptos,
is Fee.Bitcoin,
is Fee.CardanoToken,
is Fee.Common,
is Fee.Filecoin,
is Fee.Hedera,
is Fee.Kaspa,
is Fee.Sui,
is Fee.Tron,
is Fee.VeChain,
-> this
}
}
private fun Fee.increaseGasLimitBy(percentage: Int): Fee {
if (this !is Fee.Ethereum) return this
val gasLimit = this.gasLimit
val increasedGasPrice = this.amount.value?.movePointRight(this.amount.decimals)
?.divide(gasLimit.toBigDecimal(), RoundingMode.HALF_UP)
val increasedGasLimit = gasLimit.multiply(percentage.toBigInteger()).divide(HUNDRED_PERCENT)
val increasedAmount = this.amount.copy(
value = increasedGasLimit.toBigDecimal().multiply(increasedGasPrice).movePointLeft(this.amount.decimals),
)
return when (this) {
is Fee.Ethereum.EIP1559 -> copy(amount = increasedAmount, gasLimit = increasedGasLimit)
is Fee.Ethereum.Legacy -> copy(amount = increasedAmount, gasLimit = increasedGasLimit)
is Fee.Ethereum.TokenCurrency -> error("handle in [REDACTED_TASK_KEY]")
}
}
companion object {
/** 12% bump used by DEX provider fee patching. */
const val DEX_PERCENTAGE = 112
/** 5% bump used by CEX/send fee patching. */
const val SEND_PERCENTAGE = 105
private val HUNDRED_PERCENT = BigInteger("100")
}
}

View file

@ -0,0 +1,120 @@
package com.tangem.feature.swap.domain.fee
import com.tangem.blockchain.common.transaction.Fee
import com.tangem.blockchain.common.transaction.TransactionFee
import com.tangem.domain.models.currency.CryptoCurrencyStatus
import com.tangem.domain.transaction.models.TransactionFeeExtended
import com.tangem.feature.swap.domain.models.ui.FeeBucket
import com.tangem.feature.swap.domain.models.ui.SwapFee
import java.math.BigDecimal
/**
* Builds [SwapFee] instances from raw [TransactionFeeResult] payloads.
*
* [REDACTED_TASK_KEY] Phase 3. Keeps the bucket-selection rules in one place so that
* `SwapInteractor.loadSwapFee` (DEX path, CEX path) and `applySwapFee` (added in Phase 4) stay
* in sync.
*
* Bucket selection mirrors the rules the send-v2 `FeeItemConverter` uses to populate the fee
* selector list (`TransactionFee.Choosable` Slow/Market/Fast; `TransactionFee.Single`
* Market). When the caller explicitly asks for a tier other than the default `MARKET`, the
* matching [Fee] is sourced from the [TransactionFee] payload; otherwise `MARKET` is the
* default since every variant exposes a `normal` field.
*/
object SwapFeeFactory {
/**
* Builds a [SwapFee] from a [TransactionFeeResult.Loaded] (native-fee branch).
*
* @param transactionFeeResult the raw fee payload its `.fee` is the [TransactionFee] that
* determines the available buckets.
* @param selectedFeeToken the currency that pays the fee. For native fee paths this is the
* native coin status of the from-token's network.
* @param otherNativeFee bridge protocol fee from `DexFeeResult.otherNativeFee`. Zero
* unless the provider is DEX_BRIDGE.
* @param feeBucket the tier to use; defaults to [FeeBucket.MARKET]. The selected
* [SwapFee.fee] is sourced from the [TransactionFee] shape accordingly.
*/
fun fromLoaded(
transactionFeeResult: TransactionFeeResult.Loaded,
selectedFeeToken: CryptoCurrencyStatus,
otherNativeFee: BigDecimal = BigDecimal.ZERO,
feeBucket: FeeBucket = FeeBucket.MARKET,
): SwapFee = SwapFee(
fee = selectFee(transactionFeeResult.fee, feeBucket),
transactionFeeResult = transactionFeeResult,
selectedFeeToken = selectedFeeToken,
otherNativeFee = otherNativeFee,
feeBucket = feeBucket,
)
/**
* Builds a [SwapFee] from a [TransactionFeeResult.LoadedExtended] (gasless / token-fee
* branch).
*
* `LoadedExtended` always carries a single [TransactionFeeExtended.transactionFee] (no
* slow/normal/priority choice), so the bucket defaults to [FeeBucket.MARKET].
*/
fun fromLoadedExtended(
transactionFeeResult: TransactionFeeResult.LoadedExtended,
selectedFeeToken: CryptoCurrencyStatus,
otherNativeFee: BigDecimal = BigDecimal.ZERO,
feeBucket: FeeBucket = FeeBucket.MARKET,
): SwapFee = SwapFee(
fee = selectFee(transactionFeeResult.fee.transactionFee, feeBucket),
transactionFeeResult = transactionFeeResult,
selectedFeeToken = selectedFeeToken,
otherNativeFee = otherNativeFee,
feeBucket = feeBucket,
)
/**
* Convenience entry-point that picks the right [fromLoaded] / [fromLoadedExtended] variant
* automatically.
*/
fun from(
transactionFeeResult: TransactionFeeResult,
selectedFeeToken: CryptoCurrencyStatus,
otherNativeFee: BigDecimal = BigDecimal.ZERO,
feeBucket: FeeBucket = FeeBucket.MARKET,
): SwapFee = when (transactionFeeResult) {
is TransactionFeeResult.Loaded -> fromLoaded(
transactionFeeResult = transactionFeeResult,
selectedFeeToken = selectedFeeToken,
otherNativeFee = otherNativeFee,
feeBucket = feeBucket,
)
is TransactionFeeResult.LoadedExtended -> fromLoadedExtended(
transactionFeeResult = transactionFeeResult,
selectedFeeToken = selectedFeeToken,
otherNativeFee = otherNativeFee,
feeBucket = feeBucket,
)
}
/**
* Selects the concrete [Fee] from a [TransactionFee] for a given [FeeBucket].
*
* Falls back to [TransactionFee.normal] when the requested bucket is unavailable on the
* payload this happens, for example, when [FeeBucket.SLOW] is asked for on a
* [TransactionFee.Single] (which only has `normal`). Matches the behaviour of
* `FeeItemConverter.addFeeItemsFull`, which silently degrades a `Choosable`-only bucket to
* `Market` when the payload is `Single`.
*
* [FeeBucket.SUGGESTED] and [FeeBucket.CUSTOM] are not available from a plain
* [TransactionFee] (Suggested comes from `FeeStateConfiguration.Suggestion.fee`; Custom is
* user-edited). For both we fall back to `normal`; the caller is expected to override
* [SwapFee.fee] with the suggestion / custom fee when applicable.
*/
private fun selectFee(transactionFee: TransactionFee, feeBucket: FeeBucket): Fee = when (transactionFee) {
is TransactionFee.Choosable -> when (feeBucket) {
FeeBucket.SLOW -> transactionFee.minimum
FeeBucket.MARKET -> transactionFee.normal
FeeBucket.FAST -> transactionFee.priority
FeeBucket.SUGGESTED,
FeeBucket.CUSTOM,
-> transactionFee.normal
}
is TransactionFee.Single -> transactionFee.normal
}
}

View file

@ -0,0 +1,28 @@
package com.tangem.feature.swap.domain.fee
import com.tangem.blockchain.common.transaction.TransactionFee
import com.tangem.domain.transaction.models.TransactionFeeExtended
/**
* Result of a swap-fee calculation.
*
* [REDACTED_TASK_KEY] extracted from `SwapInteractorImpl.kt` into its own file alongside the other
* `fee` package types ([DexFeeResult], [CexFeeResult], [DexSwapFeeCalculator],
* [CexSwapFeeCalculator]). No behavioral change; this is purely a relocation.
*
* Two variants are required because the SDK exposes two fee shapes:
* - [Loaded] wraps a [TransactionFee] (native fee path).
* - [LoadedExtended] wraps a [TransactionFeeExtended] (gasless / token-fee path).
*
* The [from] factories let call-sites build the right variant without inspecting the concrete
* type at the call site.
*/
sealed class TransactionFeeResult {
class Loaded(val fee: TransactionFee) : TransactionFeeResult()
class LoadedExtended(val fee: TransactionFeeExtended) : TransactionFeeResult()
companion object {
fun from(fee: TransactionFee) = Loaded(fee)
fun from(fee: TransactionFeeExtended) = LoadedExtended(fee)
}
}

View file

@ -2,11 +2,12 @@ package com.tangem.feature.swap.domain.models
import java.math.BigDecimal
sealed class ExpressDataError {
@Suppress("MagicNumber")
sealed class ExpressDataError : Throwable() {
abstract val code: Int
open val message: String? = null
override val message: String? = null
data class BadRequest(override val code: Int) : ExpressDataError()
@ -56,17 +57,15 @@ sealed class ExpressDataError {
data class InvalidPayoutAddressError(override val code: Int = 992) : ExpressDataError()
data object UnknownError : ExpressDataError() {
override val code: Int = -1
}
data class UnknownError(override val code: Int = -1) : ExpressDataError()
data object TooLargeSolanaTransactionError : ExpressDataError() {
override val code: Int = -2
override val message: String = "tooLargeSolanaTransaction"
}
data class TooLargeSolanaTransactionError(
override val code: Int = -2,
override val message: String = "tooLargeSolanaTransaction",
) : ExpressDataError()
data object DexActiveSupplyError : ExpressDataError() {
override val code: Int = -3
override val message: String = "dexActiveSupplyError"
}
data class DexActiveSupplyError(
override val code: Int = -3,
override val message: String = "dexActiveSupplyError",
) : ExpressDataError()
}

View file

@ -3,20 +3,56 @@ package com.tangem.feature.swap.domain.models.domain
import com.tangem.feature.swap.domain.models.SwapAmount
/**
* Prepared swap config state that contains flags to determine
* Prepared swap config state derived from the resolved fee.
*
* @property isBalanceEnough shows is balance of token enough
* Populated by [SwapInteractor.applySwapFee] after the fee selector
* emits a `FeeSelectorUM.Content` state. Until then the quote carries a transient
* [SwapBalanceStatus.Pending]. Consumers must therefore not derive UI decisions from
* [balanceStatus] before the fee has resolved (see [SwapBalanceStatus.Pending]).
*
* @property balanceStatus unified balance-vs-fee comparison result that drives UI decisions
* (swap-button enabled, InsufficientFunds card, UnableToCoverFee warning, FeeCoverage warning).
* @property hasOutgoingTransaction whether the source currency has a pending outgoing transaction.
*/
// todo Refactor this state
data class PreparedSwapConfigState(
val isBalanceEnough: Boolean,
val feeState: SwapFeeState,
val balanceStatus: SwapBalanceStatus,
val hasOutgoingTransaction: Boolean,
val includeFeeInAmount: IncludeFeeInAmount,
)
sealed class IncludeFeeInAmount {
data class Included(val amountSubtractFee: SwapAmount) : IncludeFeeInAmount()
data object Excluded : IncludeFeeInAmount()
data object BalanceNotEnough : IncludeFeeInAmount()
/**
* Unified balance + fee check result for a swap.
*/
sealed interface SwapBalanceStatus {
/** Fee not yet resolved. DEX returns this from `loadDexSwapDataNoFee`. */
data object Pending : SwapBalanceStatus
/** Balance covers amount + fee. Fee currency balance covers fee. */
data object Sufficient : SwapBalanceStatus
/**
* CEX only: amount fits, fee does not, but amount can be reduced by `feeAmount` so the
* fee fits within the from-token balance. [adjustedAmount] is consumed by `manageCex`
* before calling `repository.findBestQuote` (the requote uses the reduced amount). It is
* also surfaced into the `FeeCoverageNotification` and into `manageWarnings` /
* `getCoinBalanceAfterTransaction` so the existential-deposit / dust / reserve checks see
* the reduced amount.
*/
data class FeeAdjustedAmount(val adjustedAmount: SwapAmount) : SwapBalanceStatus
/**
* Amount itself exceeds balance. Disables the swap button and drives the
* `InsufficientFunds` card in `StateBuilder.isInsufficientFundsCondition`.
*/
data object InsufficientAmount : SwapBalanceStatus
/**
* Amount fits, but the fee currency balance is below the fee. Drives the
* `UnableToCoverFeeWarning` notification. Carries the fee currency name and symbol so the
* warning can name the missing currency (e.g. "Not enough ETH for fee").
*/
data class InsufficientFee(
val feeCurrencyName: String?,
val feeCurrencySymbol: String?,
) : SwapBalanceStatus
}

View file

@ -1,9 +0,0 @@
package com.tangem.feature.swap.domain.models.domain
sealed class SwapFeeState {
data object Enough : SwapFeeState()
data class NotEnough(
val currencyName: String? = null,
val currencySymbol: String? = null,
) : SwapFeeState()
}

View file

@ -0,0 +1,46 @@
package com.tangem.feature.swap.domain.models.ui
/**
* Domain-level classification of a transaction fee tier.
*
* The send-v2 `FeeItem` type is intentionally **not** imported here the domain layer must not
* depend on UI types. The mapping above is enforced by a converter in the impl module.
*
* | FeeBucket | FeeItem |
* |-------------|----------------|
* | [SLOW] | `FeeItem.Slow` (built from `TransactionFee.Choosable.minimum`) |
* | [MARKET] | `FeeItem.Market` (built from `TransactionFee.Choosable.normal` or `TransactionFee.Single.normal`) |
* | [FAST] | `FeeItem.Fast` (built from `TransactionFee.Choosable.priority`) |
* | [SUGGESTED] | `FeeItem.Suggested` (built from `FeeStateConfiguration.Suggestion`) |
* | [CUSTOM] | `FeeItem.Custom` |
*
* All fee-tier analytics route through [toAnalyticsName].
*/
enum class FeeBucket {
SLOW,
MARKET,
FAST,
SUGGESTED,
CUSTOM,
;
/**
* Returns the human-readable analytics label for this bucket.
*
* Values are kept compatible with the labels previously emitted by
* `FeeType.getNameForAnalytics()` so that downstream analytics reporting does not break when
* the migration completes:
* - [SLOW] `"Min"`
* - [MARKET] `"Normal"` (same as legacy `FeeType.NORMAL`)
* - [FAST] `"Max"` (same as legacy `FeeType.PRIORITY`)
* - [SUGGESTED] `"Suggested"`
* - [CUSTOM] `"Custom"`
*/
fun toAnalyticsName(): String = when (this) {
SLOW -> "Min"
MARKET -> "Normal"
FAST -> "Max"
SUGGESTED -> "Suggested"
CUSTOM -> "Custom"
}
}

View file

@ -0,0 +1,40 @@
package com.tangem.feature.swap.domain.models.ui
import com.tangem.blockchain.common.transaction.Fee
import com.tangem.blockchain.common.transaction.TransactionFee
import com.tangem.domain.models.currency.CryptoCurrencyStatus
import com.tangem.feature.swap.domain.fee.TransactionFeeResult
import java.math.BigDecimal
/**
* Unified swap-fee result returned by `SwapInteractor.loadSwapFee`.
*
* The single fee carrier used by the swap feature. Wraps the on-chain [Fee], the
* full [TransactionFeeResult] (so gasless / token-paid sends can use the same payload), the
* selected fee token, the optional bridge protocol fee, and the fee tier classifier.
*
* @property fee the concrete [Fee] that will be signed and broadcast on-chain. For
* `TransactionFee.Single`-shaped responses this is the only choice; for
* `TransactionFee.Choosable`-shaped responses it is the bucket selected by the user (or the
* default MARKET tier when no selection has been made).
* @property transactionFeeResult the full transaction-fee payload returned by the underlying
* use case. Preserved verbatim so it can be passed through to gasless send flows
* (`CreateAndSendGaslessTransactionUseCase` requires the [TransactionFeeResult.LoadedExtended]
* variant) without re-fetching.
* @property selectedFeeToken the currency that pays the fee. Never null after this phase
* for native fees it is the from-token's native coin status; for gasless / token-fee paths it
* is whatever token the user (or `EstimateFeeForGaslessTxUseCase`) selected. Used by
* downstream balance checks and analytics.
* @property otherNativeFee bridge protocol fee (e.g. carried by `ExpressTransactionModel.DEX
* .otherNativeFeeWei` for DEX_BRIDGE providers). Always [BigDecimal.ZERO] unless the provider
* is `DEX_BRIDGE`. Propagated from [com.tangem.feature.swap.domain.fee.DexFeeResult].
* @property feeBucket tier classifier derived from the parent [TransactionFee] shape (see
* [FeeBucket] mapping table). Drives analytics through [FeeBucket.toAnalyticsName].
*/
data class SwapFee(
val fee: Fee,
val transactionFeeResult: TransactionFeeResult,
val selectedFeeToken: CryptoCurrencyStatus,
val otherNativeFee: BigDecimal,
val feeBucket: FeeBucket,
)

View file

@ -1,38 +1,31 @@
package com.tangem.feature.swap.domain.models.ui
import androidx.compose.runtime.Immutable
import com.tangem.blockchain.common.transaction.Fee
import com.tangem.core.ui.extensions.TextReference
import com.tangem.domain.appcurrency.model.AppCurrency
import com.tangem.domain.models.currency.CryptoCurrencyStatus
import com.tangem.domain.models.wallet.UserWallet
import com.tangem.domain.swap.models.SwapCurrencyStatus
import com.tangem.domain.tokens.model.warnings.CryptoCurrencyCheck
import com.tangem.feature.swap.domain.TransactionFeeResult
import com.tangem.feature.swap.domain.models.ExpressDataError
import com.tangem.feature.swap.domain.models.SwapAmount
import com.tangem.feature.swap.domain.models.domain.*
import com.tangem.feature.swap.domain.models.domain.PreparedSwapConfigState
import com.tangem.feature.swap.domain.models.domain.SwapBalanceStatus
import com.tangem.feature.swap.domain.models.domain.SwapDataModel
import com.tangem.feature.swap.domain.models.domain.SwapProvider
import java.math.BigDecimal
sealed interface SwapState {
/**
* @param txFee fee state uses for calculation and build transaction
* @param txFeeIncludeOtherNativeFee fee state uses for display and included otherNativeFee (specific for bridge)
*/
data class QuotesLoadedState(
val fromTokenInfo: TokenSwapInfo,
val toTokenInfo: TokenSwapInfo,
val priceImpact: PriceImpact,
val preparedSwapConfigState: PreparedSwapConfigState = PreparedSwapConfigState(
isBalanceEnough = false,
feeState = SwapFeeState.NotEnough(),
balanceStatus = SwapBalanceStatus.Pending,
hasOutgoingTransaction = false,
includeFeeInAmount = IncludeFeeInAmount.Excluded,
),
val permissionState: PermissionDataState = PermissionDataState.Empty,
val swapDataModel: SwapDataModel? = null,
val txFee: TxFeeState,
val currencyCheck: CryptoCurrencyCheck? = null,
val validationResult: Throwable? = null,
val minAdaValue: BigDecimal?,
@ -54,10 +47,14 @@ sealed interface SwapState {
val isTransferMode: Boolean = false,
) : SwapState
/**
* Express data failure. Carries [balanceStatus] so the error-state notifications can decide
* whether to surface a fee-coverage warning (only when status is [SwapBalanceStatus.FeeAdjustedAmount]).
*/
data class SwapError(
val fromTokenInfo: TokenSwapInfo,
val error: ExpressDataError,
val includeFeeInAmount: IncludeFeeInAmount,
val balanceStatus: SwapBalanceStatus,
) : SwapState
}
@ -109,64 +106,4 @@ data class TokenSwapInfo(
val tokenAmount: SwapAmount,
val amountFiat: BigDecimal,
val swapCurrencyStatus: SwapCurrencyStatus,
)
data class RequestApproveStateData(
val fee: TxFeeState,
val fromTokenAmount: SwapAmount,
val spenderAddress: String,
)
sealed class TxFeeState {
data class MultipleFeeState(
val normalFee: TxFee.Legacy,
val priorityFee: TxFee.Legacy,
) : TxFeeState() {
fun getFeeByType(feeType: FeeType): TxFee.Legacy {
return when (feeType) {
FeeType.NORMAL -> normalFee
FeeType.PRIORITY -> priorityFee
}
}
}
data class SingleFeeState(
val fee: TxFee.Legacy,
) : TxFeeState()
data object Empty : TxFeeState()
}
sealed class TxFee {
abstract val fee: Fee
data class FeeComponent(
override val fee: Fee,
val transactionFeeResult: TransactionFeeResult,
val selectedToken: CryptoCurrencyStatus?,
) : TxFee()
data class Legacy(
val feeValue: BigDecimal,
val feeFiatFormatted: String,
val feeCryptoFormatted: String,
val feeIncludeOtherNativeFee: BigDecimal,
val feeFiatFormattedWithNative: String,
val feeCryptoFormattedWithNative: String,
val cryptoSymbol: String,
val feeType: FeeType,
override val fee: Fee,
) : TxFee()
}
enum class FeeType {
NORMAL, PRIORITY
}
fun FeeType.getNameForAnalytics(): String {
return when (this) {
FeeType.NORMAL -> "Normal"
FeeType.PRIORITY -> "Max"
}
}
)

View file

@ -1,57 +0,0 @@
package com.tangem.feature.swap.domain.models.ui
import com.tangem.domain.models.account.Account
import com.tangem.domain.models.currency.CryptoCurrencyStatus
import com.tangem.feature.swap.domain.models.domain.CryptoCurrencySwapInfo
import com.tangem.feature.swap.domain.models.domain.SwapProvider
data class TokensDataStateExpress(
val fromGroup: CurrenciesGroup,
val toGroup: CurrenciesGroup,
val allProviders: List<SwapProvider>,
) {
companion object {
val EMPTY = TokensDataStateExpress(
fromGroup = CurrenciesGroup(
available = emptyList(),
unavailable = emptyList(),
accountCurrencyList = emptyList(),
isAfterSearch = false,
),
toGroup = CurrenciesGroup(
available = emptyList(),
unavailable = emptyList(),
accountCurrencyList = emptyList(),
isAfterSearch = false,
),
allProviders = emptyList(),
)
}
}
fun TokensDataStateExpress.getGroupWithReverse(isReverseFromTo: Boolean): CurrenciesGroup {
return if (isReverseFromTo) {
this.fromGroup
} else {
this.toGroup
}
}
data class CurrenciesGroup(
val available: List<CryptoCurrencySwapInfo>,
val unavailable: List<CryptoCurrencySwapInfo>,
val accountCurrencyList: List<AccountSwapAvailability>,
val isAfterSearch: Boolean,
)
data class AccountSwapAvailability(
val account: Account,
val currencyList: List<AccountSwapCurrency>,
)
data class AccountSwapCurrency(
val isAvailable: Boolean,
val account: Account,
val cryptoCurrencyStatus: CryptoCurrencyStatus,
val providers: List<SwapProvider>,
)

View file

@ -1,7 +1,14 @@
package com.tangem.feature.swap.domain.transfer
import arrow.core.Either
import com.tangem.blockchain.common.transaction.Fee
import com.tangem.blockchain.common.transaction.TransactionFee
import com.tangem.domain.models.currency.CryptoCurrency
import com.tangem.domain.swap.models.SwapCurrencyStatus
import com.tangem.domain.transaction.error.GetFeeError
import com.tangem.domain.transaction.error.SendTransactionError
import com.tangem.domain.transaction.models.TransactionFeeExtended
import com.tangem.feature.swap.domain.fee.TransactionFeeResult
import com.tangem.feature.swap.domain.models.ui.SwapState
interface SwapTransferInteractor {
@ -12,5 +19,25 @@ interface SwapTransferInteractor {
fromTokenAmount: String,
): SwapState
fun shouldTransferInsteadOfSwap(fromSwapCurrency: CryptoCurrency, toSwapCurrency: CryptoCurrency): Boolean
fun shouldTransferInsteadOfSwap(fromSwapCurrency: CryptoCurrency?, toSwapCurrency: CryptoCurrency?): Boolean
suspend fun loadFee(
fromSwapCurrencyStatus: SwapCurrencyStatus,
toSwapCurrencyStatus: SwapCurrencyStatus,
fromTokenAmount: String,
): Either<GetFeeError, TransactionFee>
suspend fun loadFeeExtended(
fromSwapCurrencyStatus: SwapCurrencyStatus,
toSwapCurrencyStatus: SwapCurrencyStatus,
fromTokenAmount: String,
): Either<GetFeeError, TransactionFeeExtended>
suspend fun sendTransfer(
fromSwapCurrencyStatus: SwapCurrencyStatus,
toSwapCurrencyStatus: SwapCurrencyStatus,
fromTokenAmount: String,
fee: Fee,
transactionFeeResult: TransactionFeeResult,
): Either<SendTransactionError, String>
}

View file

@ -1,5 +1,10 @@
package com.tangem.feature.swap.domain.transfer
import arrow.core.Either
import arrow.core.left
import com.tangem.blockchain.common.TransactionData
import com.tangem.blockchain.common.transaction.Fee
import com.tangem.blockchain.common.transaction.TransactionFee
import com.tangem.core.ui.extensions.stringReference
import com.tangem.core.ui.format.bigdecimal.fiat
import com.tangem.core.ui.format.bigdecimal.format
@ -10,7 +15,19 @@ import com.tangem.domain.appcurrency.extenstions.unwrap
import com.tangem.domain.appcurrency.model.AppCurrency
import com.tangem.domain.balancehiding.GetBalanceHidingSettingsUseCase
import com.tangem.domain.models.currency.CryptoCurrency
import com.tangem.domain.models.currency.CryptoCurrencyStatus
import com.tangem.domain.models.wallet.UserWallet
import com.tangem.domain.swap.models.SwapCurrencyStatus
import com.tangem.domain.transaction.error.GetFeeError
import com.tangem.domain.transaction.error.SendTransactionError
import com.tangem.domain.transaction.models.TransactionFeeExtended
import com.tangem.domain.transaction.usecase.CreateTransferTransactionUseCase
import com.tangem.domain.transaction.usecase.GetFeeUseCase
import com.tangem.domain.transaction.usecase.SendTransactionUseCase
import com.tangem.domain.transaction.usecase.gasless.CreateAndSendGaslessTransactionUseCase
import com.tangem.domain.transaction.usecase.gasless.GetFeeForGaslessUseCase
import com.tangem.domain.utils.convertToSdkAmount
import com.tangem.feature.swap.domain.fee.TransactionFeeResult
import com.tangem.feature.swap.domain.models.SwapAmount
import com.tangem.feature.swap.domain.models.ui.SwapState
import com.tangem.feature.swap.domain.models.ui.TokenSwapInfo
@ -20,11 +37,17 @@ import kotlinx.coroutines.flow.first
import java.math.BigDecimal
import javax.inject.Inject
@Suppress("LongParameterList")
class SwapTransferInteractorImpl @Inject constructor(
private val swapFeatureToggles: SwapFeatureToggles,
private val getSelectedAppCurrencyUseCase: GetSelectedAppCurrencyUseCase,
private val getBalanceHidingSettingsUseCase: GetBalanceHidingSettingsUseCase,
private val isAccountsModeEnabledUseCase: IsAccountsModeEnabledUseCase,
private val getFeeUseCase: GetFeeUseCase,
private val getFeeForGaslessUseCase: GetFeeForGaslessUseCase,
private val createTransferTransactionUseCase: CreateTransferTransactionUseCase,
private val sendTransactionUseCase: SendTransactionUseCase,
private val createAndSendGaslessTransactionUseCase: CreateAndSendGaslessTransactionUseCase,
) : SwapTransferInteractor {
override suspend fun updateTransfer(
@ -78,8 +101,8 @@ class SwapTransferInteractorImpl @Inject constructor(
}
override fun shouldTransferInsteadOfSwap(
fromSwapCurrency: CryptoCurrency,
toSwapCurrency: CryptoCurrency,
fromSwapCurrency: CryptoCurrency?,
toSwapCurrency: CryptoCurrency?,
): Boolean {
if (swapFeatureToggles.isSwapSwitchToTransferEnabled.not()) return false
val isSameCurrency = when {
@ -94,4 +117,113 @@ class SwapTransferInteractorImpl @Inject constructor(
}
return isSameCurrency
}
override suspend fun loadFee(
fromSwapCurrencyStatus: SwapCurrencyStatus,
toSwapCurrencyStatus: SwapCurrencyStatus,
fromTokenAmount: String,
): Either<GetFeeError, TransactionFee> {
val amount = fromTokenAmount.parseBigDecimalOrNull() ?: BigDecimal.ZERO
val destination = toSwapCurrencyStatus.destinationAddress() ?: return feeDataError(
message = "Destination address is null",
)
return getFeeUseCase(
amount = amount,
destination = destination,
userWallet = fromSwapCurrencyStatus.userWallet,
cryptoCurrency = fromSwapCurrencyStatus.currency,
)
}
override suspend fun loadFeeExtended(
fromSwapCurrencyStatus: SwapCurrencyStatus,
toSwapCurrencyStatus: SwapCurrencyStatus,
fromTokenAmount: String,
): Either<GetFeeError, TransactionFeeExtended> {
val amount = fromTokenAmount.parseBigDecimalOrNull() ?: BigDecimal.ZERO
val destination = toSwapCurrencyStatus.destinationAddress() ?: return feeDataError(
message = "Destination address is null",
)
val userWallet = fromSwapCurrencyStatus.userWallet
val currency = fromSwapCurrencyStatus.currency
val transactionData = createTransferTransactionUseCase(
amount = amount.convertToSdkAmount(
cryptoCurrencyStatus = fromSwapCurrencyStatus.status,
),
memo = null,
destination = destination,
userWalletId = userWallet.walletId,
network = currency.network,
).getOrNull() ?: return feeDataError("Failed to build transfer transaction")
return getFeeForGaslessUseCase(
userWallet = userWallet,
network = currency.network,
transactionData = transactionData,
)
}
override suspend fun sendTransfer(
fromSwapCurrencyStatus: SwapCurrencyStatus,
toSwapCurrencyStatus: SwapCurrencyStatus,
fromTokenAmount: String,
fee: Fee,
transactionFeeResult: TransactionFeeResult,
): Either<SendTransactionError, String> {
val amount = fromTokenAmount.parseBigDecimalOrNull()?.takeIf { it.signum() > 0 }
?: return SendTransactionError.DataError("Can't parse fromTokenAmount: $fromTokenAmount").left()
val destination = toSwapCurrencyStatus.destinationAddress()
?: return SendTransactionError.DataError("Destination address is null").left()
val userWallet = fromSwapCurrencyStatus.userWallet
val currency = fromSwapCurrencyStatus.currency
val txData = createTransferTransactionUseCase(
amount = amount.convertToSdkAmount(cryptoCurrencyStatus = fromSwapCurrencyStatus.status),
fee = fee,
memo = null,
destination = destination,
userWalletId = userWallet.walletId,
network = currency.network,
).getOrNull() ?: return SendTransactionError.DataError("Failed to build transfer transaction").left()
return sendTransferForFeeType(
userWallet = fromSwapCurrencyStatus.userWallet,
cryptoCurrencyStatus = fromSwapCurrencyStatus.status,
transactionFeeResult = transactionFeeResult,
txData = txData,
)
}
private suspend fun sendTransferForFeeType(
userWallet: UserWallet,
cryptoCurrencyStatus: CryptoCurrencyStatus,
transactionFeeResult: TransactionFeeResult,
txData: TransactionData,
): Either<SendTransactionError, String> {
val isToken = cryptoCurrencyStatus.currency is CryptoCurrency.Token
val isGaslessToken = isToken && transactionFeeResult is TransactionFeeResult.LoadedExtended
return if (isGaslessToken) {
createAndSendGaslessTransactionUseCase(
transactionData = txData,
userWallet = userWallet,
fee = transactionFeeResult.fee,
)
} else {
sendTransactionUseCase(
txData = txData,
userWallet = userWallet,
network = cryptoCurrencyStatus.currency.network,
)
}
}
private fun feeDataError(message: String): Either<GetFeeError, Nothing> {
return GetFeeError.DataError(IllegalStateException(message)).left()
}
private fun SwapCurrencyStatus.destinationAddress(): String? {
return status.value.networkAddress?.defaultAddress?.value
}
}

View file

@ -0,0 +1,881 @@
package com.tangem.feature.swap.domain
import arrow.core.right
import com.google.common.truth.Truth.assertThat
import com.tangem.blockchain.common.Amount
import com.tangem.blockchain.common.Blockchain
import com.tangem.blockchain.common.transaction.Fee
import com.tangem.blockchain.common.transaction.TransactionFee
import com.tangem.blockchainsdk.utils.toNetworkId
import com.tangem.domain.models.currency.CryptoCurrency
import com.tangem.domain.models.currency.CryptoCurrencyStatus
import com.tangem.domain.tokens.model.FeePaidCurrency
import com.tangem.domain.tokens.model.warnings.CryptoCurrencyCheck
import com.tangem.feature.swap.domain.fee.TransactionFeeResult
import com.tangem.feature.swap.domain.models.SwapAmount
import com.tangem.feature.swap.domain.models.domain.ExchangeProviderType
import com.tangem.feature.swap.domain.models.domain.PreparedSwapConfigState
import com.tangem.feature.swap.domain.models.domain.SwapBalanceStatus
import com.tangem.feature.swap.domain.models.ui.*
import io.mockk.coEvery
import io.mockk.every
import io.mockk.mockk
import kotlinx.coroutines.test.runTest
import org.junit.jupiter.api.BeforeEach
import org.junit.jupiter.api.Nested
import org.junit.jupiter.api.Test
import org.junit.jupiter.api.TestInstance
import java.math.BigDecimal
/**
* Matrix-style coverage for [SwapInteractorImpl.applySwapFee] across all combinations of:
* - Provider type: DEX / DEX_BRIDGE / CEX
* - FeePaidCurrency: Coin / Token / SameCurrency / FeeResource
* - from-token shape: Coin vs Token
*
* KEY INVARIANT ([REDACTED_TASK_KEY]):
* "For DEX, fee cannot be subtracted from the swap amount."
* When amount + fee > balance, DEX must return InsufficientFee, never FeeAdjustedAmount.
* CEX returns FeeAdjustedAmount in the same scenario.
*/
@TestInstance(TestInstance.Lifecycle.PER_CLASS)
internal class SwapInteractorImplApplySwapFeeMatrixTest : SwapInteractorImplTestBase() {
private val ethNetwork = Blockchain.Ethereum.toNetworkId()
private val lastReducedBalanceBy = BigDecimal.ZERO
@BeforeEach
fun setup() {
// Default stubs that keep all tests alive unless they override:
coEvery {
getCurrencyCheckUseCase.invoke(
userWalletId = any(),
currencyStatus = any(),
feeCurrencyStatus = any(),
amount = any(),
fee = any(),
feeCurrencyBalanceAfterTransaction = any(),
recipientAddress = any(),
)
} returns buildCurrencyCheck()
coEvery {
validateTransactionUseCase.invoke(
amount = any(),
fee = any(),
memo = any(),
destination = any(),
userWalletId = any(),
network = any(),
)
} returns Unit.right()
coEvery { walletManagersFacade.getNativeTokenBalance(any(), any(), any()) } returns BigDecimal("10")
coEvery { getFeePaidCryptoCurrencyStatusSyncUseCase.invoke(any(), any()) } returns null.right()
coEvery { currenciesRepository.createCoinCurrency(any()) } returns buildCoinCurrency()
}
// =========================================================================
// Section A: DEX/CEX asymmetry — the KEY INVARIANT
// =========================================================================
@Nested
inner class `DEX vs CEX asymmetry - fee-cannot-deduct invariant` {
/**
* GIVEN ExchangeProviderType.DEX
* fromToken is Coin, status.value.amount = 1.1 ETH (isBalanceEnough passes: 1.1 >= 1.0+0.01)
* FeePaidCurrency.Coin, walletManagersFacade.getNativeTokenBalance = 1.0 ETH
* amount = 1.0 ETH, fee = 0.01 ETH
* WHEN applySwapFee runs
* THEN balanceStatus == InsufficientFee (NOT FeeAdjustedAmount)
*
* The DEX invariant: DEX never reduces the amount to include fee.
* computeBalanceStatus for DEX/DEX_BRIDGE skips getIncludeFeeInAmountInternal entirely,
* then falls to getFeeBalanceState. With nativeBalance=1.0 and amount=1.0:
* balanceToCheck = nativeBalance(1.0) - amount(1.0) = 0 fee(0.01) InsufficientFee.
*
* NOTE: fromBalance (status.value.amount) must be > amount+fee so isBalanceEnough()
* passes and we reach getFeeBalanceState. The walletManagersFacade balance is what
* triggers the InsufficientFee via getFeeBalanceState for the coin case.
*/
@Test
fun `applySwapFee DEX with Coin fee — amount+fee greater than balance returns InsufficientFee (cannot deduct on DEX)`() =
runTest {
coEvery { currenciesRepository.getFeePaidCurrency(any(), any()) } returns FeePaidCurrency.Coin
// Native balance (for fee deduction check) = 1.0 ETH.
// After subtracting amount (1.0 ETH), 0 remains which is < fee (0.01 ETH).
coEvery {
walletManagersFacade.getNativeTokenBalance(any(), any(), any())
} returns BigDecimal("1.0")
// fromBalance must be larger than amount+fee so isBalanceEnough() passes.
// status.value.amount = 1.1 ETH: 1.1 >= 1.0+0.01=1.01 → isBalanceEnough=true
val state = buildQuotesLoadedState(
providerType = ExchangeProviderType.DEX,
fromAmount = SwapAmount(BigDecimal("1.0"), 18),
isCoin = true,
fromBalance = BigDecimal("1.1"),
)
val fee = buildSwapFeeWithCoinToken(feeValue = BigDecimal("0.01"))
val result = sut.applySwapFee(state, fee, lastReducedBalanceBy)
// DEX must NOT return FeeAdjustedAmount — it must return InsufficientFee
assertThat(result.preparedSwapConfigState.balanceStatus)
.isInstanceOf(SwapBalanceStatus.InsufficientFee::class.java)
}
/**
* CEX twin: same native-balance scenario FeeAdjustedAmount (CEX can include fee in amount).
*
* GIVEN ExchangeProviderType.CEX
* fromToken is Coin, status.value.amount = 1.1 ETH, amount = 1.0 ETH, fee = 0.01 ETH
* walletManagersFacade.getNativeTokenBalance = 1.0 ETH
* WHEN applySwapFee runs
* THEN balanceStatus == FeeAdjustedAmount (CEX auto-reduces amount)
*
* For CEX, getIncludeFeeInAmountInternal fires:
* nativeBalance = 1.0, amount = 1.0, amountWithFee = 1.01 > 1.0 = nativeBalance
* AND fee(0.01) < amount(1.0) Included FeeAdjustedAmount.
*/
@Test
fun `applySwapFee CEX with Coin fee — amount+fee greater than nativeBalance returns FeeAdjustedAmount (can deduct on CEX)`() =
runTest {
coEvery { currenciesRepository.getFeePaidCurrency(any(), any()) } returns FeePaidCurrency.Coin
// Native balance for fee calculation path
coEvery {
walletManagersFacade.getNativeTokenBalance(any(), any(), any())
} returns BigDecimal("1.0")
// fromBalance (status.value.amount) must pass isBalanceEnough for CEX too
val state = buildQuotesLoadedState(
providerType = ExchangeProviderType.CEX,
fromAmount = SwapAmount(BigDecimal("1.0"), 18),
isCoin = true,
fromBalance = BigDecimal("1.1"),
)
val fee = buildSwapFeeWithCoinToken(feeValue = BigDecimal("0.01"))
val result = sut.applySwapFee(state, fee, lastReducedBalanceBy)
assertThat(result.preparedSwapConfigState.balanceStatus)
.isInstanceOf(SwapBalanceStatus.FeeAdjustedAmount::class.java)
}
/**
* DEX_BRIDGE mirrors DEX: same nativeBalance scenario returns InsufficientFee.
*/
@Test
fun `applySwapFee DEX_BRIDGE with Coin fee — amount+fee greater than balance returns InsufficientFee`() =
runTest {
coEvery { currenciesRepository.getFeePaidCurrency(any(), any()) } returns FeePaidCurrency.Coin
coEvery {
walletManagersFacade.getNativeTokenBalance(any(), any(), any())
} returns BigDecimal("1.0")
val state = buildQuotesLoadedState(
providerType = ExchangeProviderType.DEX_BRIDGE,
fromAmount = SwapAmount(BigDecimal("1.0"), 18),
isCoin = true,
fromBalance = BigDecimal("1.1"),
)
val fee = buildSwapFeeWithCoinToken(feeValue = BigDecimal("0.01"))
val result = sut.applySwapFee(state, fee, lastReducedBalanceBy)
assertThat(result.preparedSwapConfigState.balanceStatus)
.isInstanceOf(SwapBalanceStatus.InsufficientFee::class.java)
}
/**
* DEX happy path: balance comfortably covers both amount and fee.
* Must return Sufficient, not FeeAdjustedAmount.
*/
@Test
fun `applySwapFee DEX with Coin fee — balance covers amount+fee returns Sufficient`() = runTest {
coEvery { currenciesRepository.getFeePaidCurrency(any(), any()) } returns FeePaidCurrency.Coin
coEvery {
walletManagersFacade.getNativeTokenBalance(any(), any(), any())
} returns BigDecimal("2.0")
val state = buildQuotesLoadedState(
providerType = ExchangeProviderType.DEX,
fromAmount = SwapAmount(BigDecimal("1.0"), 18),
isCoin = true,
fromBalance = BigDecimal("2.0"),
)
val fee = buildSwapFeeWithCoinToken(feeValue = BigDecimal("0.01"))
val result = sut.applySwapFee(state, fee, lastReducedBalanceBy)
assertThat(result.preparedSwapConfigState.balanceStatus)
.isInstanceOf(SwapBalanceStatus.Sufficient::class.java)
}
}
// =========================================================================
// Section B: FeePaidCurrency.Token (gasless-token) paths
// =========================================================================
@Nested
inner class `FeePaidCurrency Token paths` {
/**
* FeePaidCurrency.Token with sufficient token balance Sufficient.
* The from-token is a Token on ETH; fee is paid from a different gasless token
* whose balance (5.0) comfortably exceeds the fee (0.001).
*/
@Test
fun `applySwapFee — FeePaidCurrency Token — sufficient gasless-token balance returns Sufficient`() =
runTest {
val gaslessTokenId = mockk<CryptoCurrency.ID>(relaxed = true)
val gaslessToken = mockk<CryptoCurrency.Token>(relaxed = true) {
every { id } returns gaslessTokenId
}
val gaslessTokenStatus = mockk<CryptoCurrencyStatus>(relaxed = true) {
every { currency } returns gaslessToken
every { value.amount } returns BigDecimal("5.0")
}
// FeePaidCurrency.Token with balance=5.0 > fee=0.001 → Enough
coEvery { currenciesRepository.getFeePaidCurrency(any(), any()) } returns FeePaidCurrency.Token(
tokenId = gaslessTokenId,
name = "GasToken",
symbol = "GAS",
contractAddress = "0xGasTokenAddress",
balance = BigDecimal("5.0"),
)
val fromId = mockk<CryptoCurrency.ID>(relaxed = true)
val state = buildQuotesLoadedStateWithTokenFrom(
providerType = ExchangeProviderType.DEX,
fromAmount = SwapAmount(BigDecimal("1.0"), 18),
fromBalance = BigDecimal("10.0"),
fromTokenId = fromId,
)
// selectedFeeToken is the gasless token (different from fromToken)
val fee = buildSwapFeeWithExplicitToken(
feeValue = BigDecimal("0.001"),
tokenStatus = gaslessTokenStatus,
tokenId = gaslessTokenId,
)
val result = sut.applySwapFee(state, fee, lastReducedBalanceBy)
assertThat(result.preparedSwapConfigState.balanceStatus)
.isInstanceOf(SwapBalanceStatus.Sufficient::class.java)
}
/**
* FeePaidCurrency.Token with insufficient token balance InsufficientFee.
* The gasless token balance (0.0005) is below the fee (0.001).
*/
@Test
fun `applySwapFee — FeePaidCurrency Token — insufficient gasless-token balance returns InsufficientFee`() =
runTest {
val gaslessTokenId = mockk<CryptoCurrency.ID>(relaxed = true)
val gaslessToken = mockk<CryptoCurrency.Token>(relaxed = true) {
every { id } returns gaslessTokenId
every { name } returns "GasToken"
every { symbol } returns "GAS"
}
val gaslessTokenStatus = mockk<CryptoCurrencyStatus>(relaxed = true) {
every { currency } returns gaslessToken
every { value.amount } returns BigDecimal("0.0005")
}
// FeePaidCurrency.Token with balance=0.0005 < fee=0.001 → NotEnough
coEvery { currenciesRepository.getFeePaidCurrency(any(), any()) } returns FeePaidCurrency.Token(
tokenId = gaslessTokenId,
name = "GasToken",
symbol = "GAS",
contractAddress = "0xGasTokenAddress",
balance = BigDecimal("0.0005"),
)
val fromId = mockk<CryptoCurrency.ID>(relaxed = true)
val state = buildQuotesLoadedStateWithTokenFrom(
providerType = ExchangeProviderType.DEX,
fromAmount = SwapAmount(BigDecimal("1.0"), 18),
fromBalance = BigDecimal("10.0"),
fromTokenId = fromId,
)
val fee = buildSwapFeeWithExplicitToken(
feeValue = BigDecimal("0.001"),
tokenStatus = gaslessTokenStatus,
tokenId = gaslessTokenId,
)
val result = sut.applySwapFee(state, fee, lastReducedBalanceBy)
assertThat(result.preparedSwapConfigState.balanceStatus)
.isInstanceOf(SwapBalanceStatus.InsufficientFee::class.java)
}
/**
* FeePaidCurrency.Token verifies the fee currency name/symbol propagate into
* the InsufficientFee status so the UI can show "Not enough GAS for fee".
*/
@Test
fun `applySwapFee — FeePaidCurrency Token — InsufficientFee carries token name and symbol`() = runTest {
val gaslessTokenId = mockk<CryptoCurrency.ID>(relaxed = true)
val gaslessToken = mockk<CryptoCurrency.Token>(relaxed = true) {
every { id } returns gaslessTokenId
every { name } returns "GasToken"
every { symbol } returns "GAS"
}
val gaslessTokenStatus = mockk<CryptoCurrencyStatus>(relaxed = true) {
every { currency } returns gaslessToken
every { value.amount } returns BigDecimal("0.0005")
}
coEvery { currenciesRepository.getFeePaidCurrency(any(), any()) } returns FeePaidCurrency.Token(
tokenId = gaslessTokenId,
name = "GasToken",
symbol = "GAS",
contractAddress = "0xGasTokenAddress",
balance = BigDecimal("0.0005"),
)
val fromId = mockk<CryptoCurrency.ID>(relaxed = true)
val state = buildQuotesLoadedStateWithTokenFrom(
providerType = ExchangeProviderType.CEX,
fromAmount = SwapAmount(BigDecimal("1.0"), 18),
fromBalance = BigDecimal("10.0"),
fromTokenId = fromId,
)
val fee = buildSwapFeeWithExplicitToken(
feeValue = BigDecimal("0.001"),
tokenStatus = gaslessTokenStatus,
tokenId = gaslessTokenId,
)
val result = sut.applySwapFee(state, fee, lastReducedBalanceBy)
val status = result.preparedSwapConfigState.balanceStatus
assertThat(status).isInstanceOf(SwapBalanceStatus.InsufficientFee::class.java)
val insufficientFee = status as SwapBalanceStatus.InsufficientFee
assertThat(insufficientFee.feeCurrencySymbol).isEqualTo("GAS")
assertThat(insufficientFee.feeCurrencyName).isEqualTo("GasToken")
}
}
// =========================================================================
// Section C: FeePaidCurrency.SameCurrency paths
// =========================================================================
@Nested
inner class `FeePaidCurrency SameCurrency paths` {
/**
* FeePaidCurrency.SameCurrency on CEX: fromToken is a Token, fee is paid in the same
* token, balance comfortably covers amount + fee Sufficient.
* (This is the Cardano-style path where the fee currency == the send currency.)
*/
@Test
fun `applySwapFee CEX — FeePaidCurrency SameCurrency — sufficient balance returns Sufficient`() = runTest {
coEvery { currenciesRepository.getFeePaidCurrency(any(), any()) } returns FeePaidCurrency.SameCurrency
val state = buildQuotesLoadedState(
providerType = ExchangeProviderType.CEX,
fromAmount = SwapAmount(BigDecimal("1.0"), 18),
isCoin = false,
fromBalance = BigDecimal("10.0"),
)
// Fee is low enough: balance(10) - amount(1) = 9 > fee(0.001)
val fee = buildSwapFeeWithCoinToken(feeValue = BigDecimal("0.001"))
val result = sut.applySwapFee(state, fee, lastReducedBalanceBy)
assertThat(result.preparedSwapConfigState.balanceStatus)
.isInstanceOf(SwapBalanceStatus.Sufficient::class.java)
}
/**
* FeePaidCurrency.SameCurrency on DEX: balance - amount just covers the fee Sufficient.
* (DEX doesn't invoke getIncludeFeeInAmountInternal so it falls through to getFeeBalanceState.)
*/
@Test
fun `applySwapFee DEX — FeePaidCurrency SameCurrency — balance minus amount covers fee returns Sufficient`() =
runTest {
coEvery { currenciesRepository.getFeePaidCurrency(any(), any()) } returns FeePaidCurrency.SameCurrency
// balance=10, amount=1, fee=0.5 → balance-amount=9 > fee=0.5 → Sufficient
val state = buildQuotesLoadedState(
providerType = ExchangeProviderType.DEX,
fromAmount = SwapAmount(BigDecimal("1.0"), 18),
isCoin = false,
fromBalance = BigDecimal("10.0"),
)
val fee = buildSwapFeeWithCoinToken(feeValue = BigDecimal("0.5"))
val result = sut.applySwapFee(state, fee, lastReducedBalanceBy)
assertThat(result.preparedSwapConfigState.balanceStatus)
.isInstanceOf(SwapBalanceStatus.Sufficient::class.java)
}
/**
* FeePaidCurrency.SameCurrency: balance - amount is less than fee InsufficientFee.
*/
@Test
fun `applySwapFee — FeePaidCurrency SameCurrency — balance minus amount below fee returns InsufficientFee`() =
runTest {
coEvery { currenciesRepository.getFeePaidCurrency(any(), any()) } returns FeePaidCurrency.SameCurrency
// balance=1.0, amount=1.0, fee=0.001 → balance-amount=0 ≤ fee → NotEnough
val state = buildQuotesLoadedState(
providerType = ExchangeProviderType.DEX,
fromAmount = SwapAmount(BigDecimal("1.0"), 18),
isCoin = false,
fromBalance = BigDecimal("1.0"),
)
val fee = buildSwapFeeWithCoinToken(feeValue = BigDecimal("0.001"))
val result = sut.applySwapFee(state, fee, lastReducedBalanceBy)
assertThat(result.preparedSwapConfigState.balanceStatus)
.isInstanceOf(SwapBalanceStatus.InsufficientFee::class.java)
}
}
// =========================================================================
// Section D: FeePaidCurrency.FeeResource paths
// =========================================================================
@Nested
inner class `FeePaidCurrency FeeResource paths` {
/**
* FeeResource, isFeeResourceEnough = true Sufficient (happy path already tested
* in SwapInteractorImplApplySwapFeeTest but verified here for clarity).
*/
@Test
fun `applySwapFee — FeeResource — isFeeResourceEnough true returns Sufficient`() = runTest {
coEvery { currenciesRepository.getFeePaidCurrency(any(), any()) } returns
FeePaidCurrency.FeeResource(currency = "MANA")
coEvery { currencyChecksRepository.checkIfFeeResourceEnough(any(), any(), any()) } returns true
val state = buildQuotesLoadedState(
providerType = ExchangeProviderType.DEX,
fromAmount = SwapAmount(BigDecimal("1.0"), 18),
isCoin = true,
fromBalance = BigDecimal("10.0"),
)
val fee = buildSwapFeeWithCoinToken(feeValue = BigDecimal("0.001"))
val result = sut.applySwapFee(state, fee, lastReducedBalanceBy)
assertThat(result.preparedSwapConfigState.balanceStatus)
.isInstanceOf(SwapBalanceStatus.Sufficient::class.java)
}
/**
* FeeResource, isFeeResourceEnough = false InsufficientFee.
* This is the MISSING unhappy path that was requested in the audit.
*/
@Test
fun `applySwapFee — FeeResource — isFeeResourceEnough false returns InsufficientFee`() = runTest {
coEvery { currenciesRepository.getFeePaidCurrency(any(), any()) } returns
FeePaidCurrency.FeeResource(currency = "MANA")
coEvery { currencyChecksRepository.checkIfFeeResourceEnough(any(), any(), any()) } returns false
val state = buildQuotesLoadedState(
providerType = ExchangeProviderType.DEX,
fromAmount = SwapAmount(BigDecimal("1.0"), 18),
isCoin = true,
fromBalance = BigDecimal("10.0"),
)
val fee = buildSwapFeeWithCoinToken(feeValue = BigDecimal("0.001"))
val result = sut.applySwapFee(state, fee, lastReducedBalanceBy)
assertThat(result.preparedSwapConfigState.balanceStatus)
.isInstanceOf(SwapBalanceStatus.InsufficientFee::class.java)
}
/**
* FeeResource on CEX: isFeeResourceEnough = false InsufficientFee even for CEX,
* because CEX's FeeAdjustedAmount path is only taken for native-coin fee deduction,
* not for fee resources.
*/
@Test
fun `applySwapFee CEX — FeeResource — isFeeResourceEnough false returns InsufficientFee`() = runTest {
coEvery { currenciesRepository.getFeePaidCurrency(any(), any()) } returns
FeePaidCurrency.FeeResource(currency = "MANA")
coEvery { currencyChecksRepository.checkIfFeeResourceEnough(any(), any(), any()) } returns false
val state = buildQuotesLoadedState(
providerType = ExchangeProviderType.CEX,
fromAmount = SwapAmount(BigDecimal("1.0"), 18),
isCoin = true,
fromBalance = BigDecimal("10.0"),
)
val fee = buildSwapFeeWithCoinToken(feeValue = BigDecimal("0.001"))
val result = sut.applySwapFee(state, fee, lastReducedBalanceBy)
assertThat(result.preparedSwapConfigState.balanceStatus)
.isInstanceOf(SwapBalanceStatus.InsufficientFee::class.java)
}
}
// =========================================================================
// Section E: FeePaidCurrency.Coin — from-token is Token (fee paid separately)
// =========================================================================
@Nested
inner class `FeePaidCurrency Coin - from is Token` {
/**
* From-token is an ERC-20 Token, FeePaidCurrency.Coin (ETH pays the gas).
* Native balance comfortably covers the fee Sufficient.
* No amount+fee concern because the fee currency (ETH) != from-token (USDC).
*/
@Test
fun `applySwapFee DEX — Coin fee — from is Token — native balance covers fee returns Sufficient`() =
runTest {
coEvery { currenciesRepository.getFeePaidCurrency(any(), any()) } returns FeePaidCurrency.Coin
coEvery {
walletManagersFacade.getNativeTokenBalance(any(), any(), any())
} returns BigDecimal("0.5")
val state = buildQuotesLoadedState(
providerType = ExchangeProviderType.DEX,
fromAmount = SwapAmount(BigDecimal("100.0"), 6), // 100 USDC
isCoin = false,
fromBalance = BigDecimal("200.0"),
)
val fee = buildSwapFeeWithCoinToken(feeValue = BigDecimal("0.001"))
val result = sut.applySwapFee(state, fee, lastReducedBalanceBy)
assertThat(result.preparedSwapConfigState.balanceStatus)
.isInstanceOf(SwapBalanceStatus.Sufficient::class.java)
}
/**
* From-token is an ERC-20 Token, FeePaidCurrency.Coin.
* Native balance (0.0001 ETH) is less than fee (0.001 ETH) InsufficientFee.
* The from-token balance (200 USDC) is irrelevant for the fee check.
*/
@Test
fun `applySwapFee DEX — Coin fee — from is Token — native balance below fee returns InsufficientFee`() =
runTest {
coEvery { currenciesRepository.getFeePaidCurrency(any(), any()) } returns FeePaidCurrency.Coin
coEvery {
walletManagersFacade.getNativeTokenBalance(any(), any(), any())
} returns BigDecimal("0.0001")
val state = buildQuotesLoadedState(
providerType = ExchangeProviderType.DEX,
fromAmount = SwapAmount(BigDecimal("100.0"), 6),
isCoin = false,
fromBalance = BigDecimal("200.0"),
)
val fee = buildSwapFeeWithCoinToken(feeValue = BigDecimal("0.001"))
val result = sut.applySwapFee(state, fee, lastReducedBalanceBy)
assertThat(result.preparedSwapConfigState.balanceStatus)
.isInstanceOf(SwapBalanceStatus.InsufficientFee::class.java)
}
/**
* CEX + from is Token + FeePaidCurrency.Coin.
* Amount (100) token balance (200). Native balance (0.0001) < fee (0.001).
*
* For CEX the getIncludeFeeInAmountInternal path runs. Because feePaidCurrency is NOT
* a same-currency-token (fromToken != feeToken), it falls to getIncludeFeeInAmountForNative
* which detects fromCurrency is CryptoCurrency.Token, then checks nativeBalance >= fee.
* 0.0001 < 0.001 BalanceNotEnough falls through to getFeeBalanceState InsufficientFee.
*
* Note: CEX does NOT return FeeAdjustedAmount when from-token is a Token because
* feeAdjustedAmount only applies to the native-coin-from path in getIncludeFeeAmountForCoinFee.
*/
@Test
fun `applySwapFee CEX — Coin fee — from is Token — native balance below fee returns InsufficientFee`() =
runTest {
coEvery { currenciesRepository.getFeePaidCurrency(any(), any()) } returns FeePaidCurrency.Coin
coEvery {
walletManagersFacade.getNativeTokenBalance(any(), any(), any())
} returns BigDecimal("0.0001")
val state = buildQuotesLoadedState(
providerType = ExchangeProviderType.CEX,
fromAmount = SwapAmount(BigDecimal("100.0"), 6),
isCoin = false,
fromBalance = BigDecimal("200.0"),
)
val fee = buildSwapFeeWithCoinToken(feeValue = BigDecimal("0.001"))
val result = sut.applySwapFee(state, fee, lastReducedBalanceBy)
assertThat(result.preparedSwapConfigState.balanceStatus)
.isInstanceOf(SwapBalanceStatus.InsufficientFee::class.java)
}
}
// =========================================================================
// Section F: Amount-alone insufficient (InsufficientAmount)
// =========================================================================
@Nested
inner class `InsufficientAmount paths` {
/**
* DEX + fromToken is Coin + amount > balance InsufficientAmount regardless of fee.
* isBalanceEnough() checks amount + fee for Coin, so balance < amount alone InsufficientAmount.
*/
@Test
fun `applySwapFee DEX — Coin — amount exceeds balance returns InsufficientAmount`() = runTest {
coEvery { currenciesRepository.getFeePaidCurrency(any(), any()) } returns FeePaidCurrency.Coin
coEvery {
walletManagersFacade.getNativeTokenBalance(any(), any(), any())
} returns BigDecimal("0.5")
// Amount = 1.0 but native balance (used for coins) = 0.5
val state = buildQuotesLoadedState(
providerType = ExchangeProviderType.DEX,
fromAmount = SwapAmount(BigDecimal("1.0"), 18),
isCoin = true,
fromBalance = BigDecimal("0.5"), // status.value.amount used by getTokenBalance
)
val fee = buildSwapFeeWithCoinToken(feeValue = BigDecimal("0.001"))
val result = sut.applySwapFee(state, fee, lastReducedBalanceBy)
assertThat(result.preparedSwapConfigState.balanceStatus)
.isInstanceOf(SwapBalanceStatus.InsufficientAmount::class.java)
}
/**
* From-token is an ERC-20 Token; amount > token balance InsufficientAmount.
* The native balance is irrelevant for the amount check when from is Token
* (FeePaidCurrency.Coin token balance check only for isBalanceEnough).
*/
@Test
fun `applySwapFee — Token from — amount exceeds token balance returns InsufficientAmount`() = runTest {
coEvery { currenciesRepository.getFeePaidCurrency(any(), any()) } returns FeePaidCurrency.Coin
coEvery {
walletManagersFacade.getNativeTokenBalance(any(), any(), any())
} returns BigDecimal("10.0") // plenty of ETH for fee
// amount = 100 USDC but fromBalance = 50 USDC
val state = buildQuotesLoadedState(
providerType = ExchangeProviderType.DEX,
fromAmount = SwapAmount(BigDecimal("100.0"), 6),
isCoin = false,
fromBalance = BigDecimal("50.0"),
)
val fee = buildSwapFeeWithCoinToken(feeValue = BigDecimal("0.001"))
val result = sut.applySwapFee(state, fee, lastReducedBalanceBy)
assertThat(result.preparedSwapConfigState.balanceStatus)
.isInstanceOf(SwapBalanceStatus.InsufficientAmount::class.java)
}
}
// =========================================================================
// Section G: FeeAdjustedAmount carries the correct adjusted value
// =========================================================================
@Nested
inner class `FeeAdjustedAmount value correctness` {
/**
* CEX + Coin from + amount+fee just barely doesn't fit.
* The adjusted amount must be nativeBalance - fee (not zero, not the original amount).
*
* Scenario:
* status.value.amount (fromBalance for isBalanceEnough) = 1.1
* walletManagersFacade nativeBalance = 1.0
* amount = 0.999, fee = 0.005
*
* isBalanceEnough: 1.1 >= 0.999 + 0.005 = 1.004 TRUE
* getIncludeFeeAmountForCoinFee:
* nativeBalance = 1.0
* amount(0.999) nativeBalance(1.0)
* amountWithFee(1.004) > nativeBalance(1.0)
* fee(0.005) < amount(0.999)
* Included: adjustedAmount = nativeBalance(1.0) - fee(0.005) = 0.995
*/
@Test
fun `applySwapFee CEX — FeeAdjustedAmount — adjustedAmount equals nativeBalance minus fee`() = runTest {
coEvery { currenciesRepository.getFeePaidCurrency(any(), any()) } returns FeePaidCurrency.Coin
coEvery {
walletManagersFacade.getNativeTokenBalance(any(), any(), any())
} returns BigDecimal("1.0")
val state = buildQuotesLoadedState(
providerType = ExchangeProviderType.CEX,
fromAmount = SwapAmount(BigDecimal("0.999"), 18),
isCoin = true,
fromBalance = BigDecimal("1.1"), // larger than amount+fee so isBalanceEnough passes
)
val fee = buildSwapFeeWithCoinToken(feeValue = BigDecimal("0.005"))
val result = sut.applySwapFee(state, fee, lastReducedBalanceBy)
val status = result.preparedSwapConfigState.balanceStatus
assertThat(status).isInstanceOf(SwapBalanceStatus.FeeAdjustedAmount::class.java)
val adjusted = status as SwapBalanceStatus.FeeAdjustedAmount
// adjustedAmount = nativeBalance(1.0) - fee(0.005) = 0.995
assertThat(adjusted.adjustedAmount.value).isEquivalentAccordingToCompareTo(BigDecimal("0.995"))
}
}
// =========================================================================
// Helpers — local builders (scope-specific, private to this test class)
// =========================================================================
private fun buildCurrencyCheck(): CryptoCurrencyCheck = CryptoCurrencyCheck(
dustValue = null,
reserveAmount = null,
minimumSendAmount = null,
existentialDeposit = null,
utxoAmountLimit = null,
isAccountFunded = true,
rentWarning = null,
isMemoRequired = false,
)
/**
* Builds a QuotesLoadedState with the specified provider and a [CryptoCurrency.Coin] from-token
* (when [isCoin] = true) or a [CryptoCurrency.Token] from-token (when [isCoin] = false).
*/
private fun buildQuotesLoadedState(
providerType: ExchangeProviderType,
fromAmount: SwapAmount,
isCoin: Boolean,
fromBalance: BigDecimal,
): SwapState.QuotesLoadedState {
val from = buildSwapCurrencyStatus(
networkRawId = ethNetwork,
isCoin = isCoin,
amount = fromBalance,
)
val to = buildSwapCurrencyStatus(networkRawId = ethNetwork)
return SwapState.QuotesLoadedState(
fromTokenInfo = TokenSwapInfo(
tokenAmount = fromAmount,
swapCurrencyStatus = from,
amountFiat = BigDecimal.ZERO,
),
toTokenInfo = TokenSwapInfo(
tokenAmount = SwapAmount(BigDecimal("0.5"), 18),
swapCurrencyStatus = to,
amountFiat = BigDecimal.ZERO,
),
priceImpact = PriceImpact.Empty,
preparedSwapConfigState = PreparedSwapConfigState(
balanceStatus = SwapBalanceStatus.Pending,
hasOutgoingTransaction = false,
),
permissionState = PermissionDataState.Empty,
swapDataModel = null,
currencyCheck = null,
validationResult = null,
minAdaValue = null,
swapProvider = buildSwapProvider(providerType),
)
}
/**
* Like [buildQuotesLoadedState] but creates a Token from-currency with the given [fromTokenId].
*/
private fun buildQuotesLoadedStateWithTokenFrom(
providerType: ExchangeProviderType,
fromAmount: SwapAmount,
fromBalance: BigDecimal,
fromTokenId: CryptoCurrency.ID,
): SwapState.QuotesLoadedState {
val from = buildSwapCurrencyStatus(
networkRawId = ethNetwork,
isCoin = false,
amount = fromBalance,
contractAddress = "0xFromTokenAddress",
)
// Rewire the id on the currency mock to be the distinct fromTokenId
every { from.status.currency.id } returns fromTokenId
val to = buildSwapCurrencyStatus(networkRawId = ethNetwork)
return SwapState.QuotesLoadedState(
fromTokenInfo = TokenSwapInfo(
tokenAmount = fromAmount,
swapCurrencyStatus = from,
amountFiat = BigDecimal.ZERO,
),
toTokenInfo = TokenSwapInfo(
tokenAmount = SwapAmount(BigDecimal("0.5"), 18),
swapCurrencyStatus = to,
amountFiat = BigDecimal.ZERO,
),
priceImpact = PriceImpact.Empty,
preparedSwapConfigState = PreparedSwapConfigState(
balanceStatus = SwapBalanceStatus.Pending,
hasOutgoingTransaction = false,
),
permissionState = PermissionDataState.Empty,
swapDataModel = null,
currencyCheck = null,
validationResult = null,
minAdaValue = null,
swapProvider = buildSwapProvider(providerType),
)
}
/**
* Builds a [com.tangem.feature.swap.domain.models.ui.SwapFee] where the [selectedFeeToken]
* holds a [CryptoCurrency.Coin] the normal native-coin fee scenario.
*/
private fun buildSwapFeeWithCoinToken(
feeValue: BigDecimal,
otherNativeFee: BigDecimal = BigDecimal.ZERO,
): com.tangem.feature.swap.domain.models.ui.SwapFee {
val amount = mockk<Amount>(relaxed = true) {
every { value } returns feeValue
}
val fee = mockk<Fee.Common>(relaxed = true) {
every { this@mockk.amount } returns amount
}
val coinCurrency = mockk<CryptoCurrency.Coin>(relaxed = true)
val feeTokenStatus = mockk<CryptoCurrencyStatus>(relaxed = true) {
every { currency } returns coinCurrency
}
return com.tangem.feature.swap.domain.models.ui.SwapFee(
fee = fee,
transactionFeeResult = TransactionFeeResult.Loaded(mockk<TransactionFee.Single>(relaxed = true)),
selectedFeeToken = feeTokenStatus,
otherNativeFee = otherNativeFee,
feeBucket = FeeBucket.MARKET,
)
}
/**
* Builds a [com.tangem.feature.swap.domain.models.ui.SwapFee] where [selectedFeeToken]
* holds an explicit [CryptoCurrency.Token] the gasless-token fee scenario.
* The [tokenId] must match the one used in the gasless token mock.
*/
private fun buildSwapFeeWithExplicitToken(
feeValue: BigDecimal,
tokenStatus: CryptoCurrencyStatus,
tokenId: CryptoCurrency.ID,
): com.tangem.feature.swap.domain.models.ui.SwapFee {
val amount = mockk<Amount>(relaxed = true) {
every { value } returns feeValue
}
val fee = mockk<Fee.Common>(relaxed = true) {
every { this@mockk.amount } returns amount
}
return com.tangem.feature.swap.domain.models.ui.SwapFee(
fee = fee,
transactionFeeResult = TransactionFeeResult.Loaded(mockk<TransactionFee.Single>(relaxed = true)),
selectedFeeToken = tokenStatus,
otherNativeFee = BigDecimal.ZERO,
feeBucket = FeeBucket.MARKET,
)
}
}

View file

@ -0,0 +1,258 @@
package com.tangem.feature.swap.domain
import arrow.core.right
import com.google.common.truth.Truth.assertThat
import com.tangem.blockchain.common.Amount
import com.tangem.blockchain.common.Blockchain
import com.tangem.blockchain.common.transaction.Fee
import com.tangem.blockchain.common.transaction.TransactionFee
import com.tangem.blockchainsdk.utils.toNetworkId
import com.tangem.domain.models.currency.CryptoCurrencyStatus
import com.tangem.domain.tokens.model.FeePaidCurrency
import com.tangem.domain.tokens.model.warnings.CryptoCurrencyCheck
import com.tangem.feature.swap.domain.fee.TransactionFeeResult
import com.tangem.feature.swap.domain.models.SwapAmount
import com.tangem.feature.swap.domain.models.domain.ExchangeProviderType
import com.tangem.feature.swap.domain.models.domain.PreparedSwapConfigState
import com.tangem.feature.swap.domain.models.domain.SwapBalanceStatus
import com.tangem.feature.swap.domain.models.ui.*
import io.mockk.coEvery
import io.mockk.every
import io.mockk.mockk
import kotlinx.coroutines.test.runTest
import org.junit.jupiter.api.BeforeEach
import org.junit.jupiter.api.Test
import org.junit.jupiter.api.TestInstance
import java.math.BigDecimal
/**
* Tests for [SwapInteractorImpl.applySwapFee] [REDACTED_TASK_KEY] Phase 4.
*
* Verifies:
* - The fee value (including bridge `otherNativeFee`) propagates to `feeState`, `isBalanceEnough`,
* and `includeFeeInAmount`.
* - Each [FeePaidCurrency] branch is recomputed correctly: Coin / SameCurrency / Token / FeeResource.
* - Bridge boundary: when native balance is between `fee` and `fee + otherNativeFee`,
* `feeState` flips from Enough to NotEnough.
* - Idempotency: applying the same SwapFee twice yields equal state.
*/
@TestInstance(TestInstance.Lifecycle.PER_CLASS)
internal class SwapInteractorImplApplySwapFeeTest : SwapInteractorImplTestBase() {
private val ethNetwork = Blockchain.Ethereum.toNetworkId()
private val lastReducedBalanceBy = BigDecimal.ZERO
@BeforeEach
fun setup() {
coEvery {
getCurrencyCheckUseCase.invoke(
userWalletId = any(),
currencyStatus = any(),
feeCurrencyStatus = any(),
amount = any(),
fee = any(),
feeCurrencyBalanceAfterTransaction = any(),
recipientAddress = any(),
)
} returns buildCurrencyCheck()
coEvery {
validateTransactionUseCase.invoke(
amount = any(),
fee = any(),
memo = any(),
destination = any(),
userWalletId = any(),
network = any(),
)
} returns Unit.right()
coEvery { walletManagersFacade.getNativeTokenBalance(any(), any(), any()) } returns BigDecimal("10")
coEvery { getFeePaidCryptoCurrencyStatusSyncUseCase.invoke(any(), any()) } returns null.right()
coEvery { currenciesRepository.createCoinCurrency(any()) } returns buildCoinCurrency()
}
@Test
fun `applySwapFee recomputes balanceStatus to Sufficient when native balance covers fee`() = runTest {
coEvery { currenciesRepository.getFeePaidCurrency(any(), any()) } returns FeePaidCurrency.Coin
coEvery { walletManagersFacade.getNativeTokenBalance(any(), any(), any()) } returns BigDecimal("10")
val state = buildQuotesLoadedState(
fromAmount = SwapAmount(BigDecimal("1"), 18),
isCoin = true,
fromBalance = BigDecimal("10"),
)
val swapFee = buildSwapFee(feeValue = BigDecimal("0.001"), otherNativeFee = BigDecimal.ZERO)
val patched = sut.applySwapFee(state, swapFee, lastReducedBalanceBy)
assertThat(patched.preparedSwapConfigState.balanceStatus).isInstanceOf(SwapBalanceStatus.Sufficient::class.java)
}
@Test
fun `applySwapFee recomputes balanceStatus to InsufficientFee when native balance below fee`() = runTest {
coEvery { currenciesRepository.getFeePaidCurrency(any(), any()) } returns FeePaidCurrency.Coin
coEvery { walletManagersFacade.getNativeTokenBalance(any(), any(), any()) } returns BigDecimal("0.0001")
val state = buildQuotesLoadedState(
fromAmount = SwapAmount(BigDecimal("1"), 18),
isCoin = false,
fromBalance = BigDecimal("10"),
)
val swapFee = buildSwapFee(feeValue = BigDecimal("0.01"))
val patched = sut.applySwapFee(state, swapFee, lastReducedBalanceBy)
assertThat(patched.preparedSwapConfigState.balanceStatus)
.isInstanceOf(SwapBalanceStatus.InsufficientFee::class.java)
}
@Test
fun `applySwapFee — bridge otherNativeFee — boundary flips balanceStatus to InsufficientFee`() = runTest {
// From-token is a Token, native fee is small enough alone but combined with otherNativeFee exceeds balance.
coEvery { currenciesRepository.getFeePaidCurrency(any(), any()) } returns FeePaidCurrency.Coin
coEvery { walletManagersFacade.getNativeTokenBalance(any(), any(), any()) } returns BigDecimal("0.0015")
val state = buildQuotesLoadedState(
fromAmount = SwapAmount(BigDecimal("1"), 18),
isCoin = false,
fromBalance = BigDecimal("10"),
)
// fee=0.001, otherNativeFee=0.001 => feeToCheck=0.002 > 0.0015 nativeBalance → InsufficientFee
val swapFee = buildSwapFee(
feeValue = BigDecimal("0.001"),
otherNativeFee = BigDecimal("0.001"),
)
val patched = sut.applySwapFee(state, swapFee, lastReducedBalanceBy)
assertThat(patched.preparedSwapConfigState.balanceStatus)
.isInstanceOf(SwapBalanceStatus.InsufficientFee::class.java)
}
@Test
fun `applySwapFee — bridge otherNativeFee — Sufficient when balance covers combined fee`() = runTest {
coEvery { currenciesRepository.getFeePaidCurrency(any(), any()) } returns FeePaidCurrency.Coin
coEvery { walletManagersFacade.getNativeTokenBalance(any(), any(), any()) } returns BigDecimal("0.005")
val state = buildQuotesLoadedState(
fromAmount = SwapAmount(BigDecimal("1"), 18),
isCoin = false,
fromBalance = BigDecimal("10"),
)
// fee=0.001, otherNativeFee=0.001 => feeToCheck=0.002 <= 0.005 nativeBalance → Sufficient
val swapFee = buildSwapFee(
feeValue = BigDecimal("0.001"),
otherNativeFee = BigDecimal("0.001"),
)
val patched = sut.applySwapFee(state, swapFee, lastReducedBalanceBy)
assertThat(patched.preparedSwapConfigState.balanceStatus).isInstanceOf(SwapBalanceStatus.Sufficient::class.java)
}
@Test
fun `applySwapFee — FeeResource branch flips to Sufficient on isFeeResourceEnough`() = runTest {
coEvery {
currenciesRepository.getFeePaidCurrency(any(), any())
} returns FeePaidCurrency.FeeResource(currency = "FEE")
coEvery { currencyChecksRepository.checkIfFeeResourceEnough(any(), any(), any()) } returns true
val state = buildQuotesLoadedState(
fromAmount = SwapAmount(BigDecimal("1"), 18),
isCoin = true,
fromBalance = BigDecimal("10"),
)
val swapFee = buildSwapFee(feeValue = BigDecimal("0.001"))
val patched = sut.applySwapFee(state, swapFee, lastReducedBalanceBy)
// Stubbed isFeeResourceEnough = true => Sufficient
assertThat(patched.preparedSwapConfigState.balanceStatus).isInstanceOf(SwapBalanceStatus.Sufficient::class.java)
}
@Test
fun `applySwapFee is idempotent — applying twice yields equal state`() = runTest {
coEvery { currenciesRepository.getFeePaidCurrency(any(), any()) } returns FeePaidCurrency.Coin
coEvery { walletManagersFacade.getNativeTokenBalance(any(), any(), any()) } returns BigDecimal("10")
val state = buildQuotesLoadedState(
fromAmount = SwapAmount(BigDecimal("1"), 18),
isCoin = true,
fromBalance = BigDecimal("10"),
)
val swapFee = buildSwapFee(feeValue = BigDecimal("0.001"))
val first = sut.applySwapFee(state, swapFee, lastReducedBalanceBy)
val second = sut.applySwapFee(first, swapFee, lastReducedBalanceBy)
assertThat(first.preparedSwapConfigState).isEqualTo(second.preparedSwapConfigState)
}
private fun buildCurrencyCheck(): CryptoCurrencyCheck = CryptoCurrencyCheck(
dustValue = null,
reserveAmount = null,
minimumSendAmount = null,
existentialDeposit = null,
utxoAmountLimit = null,
isAccountFunded = true,
rentWarning = null,
isMemoRequired = false,
)
private fun buildQuotesLoadedState(
fromAmount: SwapAmount,
isCoin: Boolean,
fromBalance: BigDecimal,
): SwapState.QuotesLoadedState {
val from = buildSwapCurrencyStatus(
networkRawId = ethNetwork,
isCoin = isCoin,
amount = fromBalance,
)
val to = buildSwapCurrencyStatus(networkRawId = ethNetwork)
return SwapState.QuotesLoadedState(
fromTokenInfo = TokenSwapInfo(
tokenAmount = fromAmount,
swapCurrencyStatus = from,
amountFiat = BigDecimal.ZERO,
),
toTokenInfo = TokenSwapInfo(
tokenAmount = SwapAmount(BigDecimal("0.5"), 18),
swapCurrencyStatus = to,
amountFiat = BigDecimal.ZERO,
),
priceImpact = PriceImpact.Empty,
preparedSwapConfigState = PreparedSwapConfigState(
balanceStatus = SwapBalanceStatus.Pending,
hasOutgoingTransaction = false,
),
permissionState = PermissionDataState.Empty,
swapDataModel = null,
currencyCheck = null,
validationResult = null,
minAdaValue = null,
swapProvider = buildSwapProvider(ExchangeProviderType.DEX),
)
}
private fun buildSwapFee(
feeValue: BigDecimal,
otherNativeFee: BigDecimal = BigDecimal.ZERO,
): SwapFee {
val amount = mockk<Amount>(relaxed = true) {
every { value } returns feeValue
}
val fee = mockk<Fee.Common>(relaxed = true) {
every { this@mockk.amount } returns amount
}
val feeTokenStatus = mockk<CryptoCurrencyStatus>(relaxed = true) {
every { currency } returns buildCoinCurrency()
}
return SwapFee(
fee = fee,
transactionFeeResult = TransactionFeeResult.Loaded(mockk<TransactionFee.Single>(relaxed = true)),
selectedFeeToken = feeTokenStatus,
otherNativeFee = otherNativeFee,
feeBucket = FeeBucket.MARKET,
)
}
}

View file

@ -7,7 +7,6 @@ import com.google.common.truth.Truth.assertThat
import com.tangem.blockchain.blockchains.solana.SolanaTransactionHelper
import com.tangem.blockchain.common.Blockchain
import com.tangem.blockchain.common.TransactionExtras
import com.tangem.blockchain.common.transaction.TransactionFee
import com.tangem.blockchainsdk.utils.toNetworkId
import com.tangem.domain.models.StatusSource
import com.tangem.domain.models.currency.CryptoCurrency
@ -96,7 +95,6 @@ internal class SwapInteractorImplFindBestQuoteTest : SwapInteractorImplTestBase(
}
coEvery { multiQuoteStatusFetcher.invoke(any()) } returns Unit.right()
coEvery { getFeePaidCryptoCurrencyStatusSyncUseCase.invoke(any(), any()) } returns null.right()
coEvery { multiWalletCryptoCurrenciesSupplier.getSyncOrNull(any()) } returns null
coEvery { currenciesRepository.createCoinCurrency(any()) } returns buildCoinCurrency()
every { allowPermissionsHandler.isAddressAllowanceInProgress(any()) } returns false
coEvery {
@ -109,9 +107,6 @@ internal class SwapInteractorImplFindBestQuoteTest : SwapInteractorImplTestBase(
} returns (AllowanceInfo.Enough(allowance = BigDecimal("1000")) as AllowanceInfo).right()
every { createTransactionExtrasUseCase.invoke(data = any(), network = any()) } returns
mockk<TransactionExtras>(relaxed = true).right()
coEvery {
getFeeUseCase.invoke(userWallet = any(), network = any(), transactionData = any())
} returns mockk<TransactionFee.Single>(relaxed = true).right()
}
@Nested
@ -132,7 +127,7 @@ internal class SwapInteractorImplFindBestQuoteTest : SwapInteractorImplTestBase(
providers = listOf(dexProvider, cexProvider),
amountToSwap = "0",
reduceBalanceBy = BigDecimal.ZERO,
txFeeSealedState = buildTxFeeSealedState(),
)
// Then
@ -155,7 +150,7 @@ internal class SwapInteractorImplFindBestQuoteTest : SwapInteractorImplTestBase(
providers = listOf(provider),
amountToSwap = "not-a-number",
reduceBalanceBy = BigDecimal.ZERO,
txFeeSealedState = buildTxFeeSealedState(),
)
// Then
@ -176,7 +171,7 @@ internal class SwapInteractorImplFindBestQuoteTest : SwapInteractorImplTestBase(
providers = emptyList(),
amountToSwap = "1.0",
reduceBalanceBy = BigDecimal.ZERO,
txFeeSealedState = buildTxFeeSealedState(),
)
// Then
@ -243,7 +238,7 @@ internal class SwapInteractorImplFindBestQuoteTest : SwapInteractorImplTestBase(
providers = listOf(dexProvider),
amountToSwap = "1.0",
reduceBalanceBy = BigDecimal.ZERO,
txFeeSealedState = buildTxFeeSealedState(),
)
// Then — has a result entry for the DEX provider; type of state is decided by internal logic
@ -271,7 +266,7 @@ internal class SwapInteractorImplFindBestQuoteTest : SwapInteractorImplTestBase(
providers = listOf(dexProvider),
amountToSwap = "1.0",
reduceBalanceBy = BigDecimal.ZERO,
txFeeSealedState = buildTxFeeSealedState(),
)
// Then
@ -279,54 +274,58 @@ internal class SwapInteractorImplFindBestQuoteTest : SwapInteractorImplTestBase(
val state = result[dexProvider]
assertThat(state).isInstanceOf(SwapState.SwapError::class.java)
val swapError = (state ?: error("state must not be null")) as SwapState.SwapError
assertThat(swapError.error).isEqualTo(ExpressDataError.DexActiveSupplyError)
assertThat(swapError.error).isEqualTo(ExpressDataError.DexActiveSupplyError())
}
@Test
fun `should set isBalanceEnough to false when from-token balance is less than swap amount`() = runTest {
// Given — balance is 0.01, swap amount is 1.0 → insufficient
val dexProvider = buildSwapProvider(ExchangeProviderType.DEX)
val fromStatus = buildSwapCurrencyStatus(
networkRawId = ethNetwork,
contractAddress = "0",
isCoin = true,
amount = BigDecimal("0.01"),
)
val toStatus = buildSwapCurrencyStatus(networkRawId = btcNetwork)
val quoteModel = buildQuoteModel(toAmount = BigDecimal("0.5"))
coEvery {
repository.findBestQuote(
userWallet = any(),
fromContractAddress = any(),
fromNetwork = any(),
toContractAddress = any(),
toNetwork = any(),
fromAmount = any(),
fromDecimals = any(),
toDecimals = any(),
providerId = dexProvider.providerId,
rateType = any(),
fun `should set balanceStatus to InsufficientAmount when from-token balance is less than swap amount`() =
runTest {
// Given — balance is 0.01, swap amount is 1.0 → insufficient
val dexProvider = buildSwapProvider(ExchangeProviderType.DEX)
val fromStatus = buildSwapCurrencyStatus(
networkRawId = ethNetwork,
contractAddress = "0",
isCoin = true,
amount = BigDecimal("0.01"),
)
} returns quoteModel.right()
val toStatus = buildSwapCurrencyStatus(networkRawId = btcNetwork)
val quoteModel = buildQuoteModel(toAmount = BigDecimal("0.5"))
// When
val result = sut.findBestQuote(
fromSwapCurrencyStatus = fromStatus,
toSwapCurrencyStatus = toStatus,
providers = listOf(dexProvider),
amountToSwap = "1.0",
reduceBalanceBy = BigDecimal.ZERO,
txFeeSealedState = buildTxFeeSealedState(),
)
coEvery {
repository.findBestQuote(
userWallet = any(),
fromContractAddress = any(),
fromNetwork = any(),
toContractAddress = any(),
toNetwork = any(),
fromAmount = any(),
fromDecimals = any(),
toDecimals = any(),
providerId = dexProvider.providerId,
rateType = any(),
)
} returns quoteModel.right()
// Then
assertThat(result).hasSize(1)
val state = result[dexProvider]
assertThat(state).isInstanceOf(SwapState.QuotesLoadedState::class.java)
val loaded = state as SwapState.QuotesLoadedState
assertThat(loaded.preparedSwapConfigState.isBalanceEnough).isFalse()
}
// When
val result = sut.findBestQuote(
fromSwapCurrencyStatus = fromStatus,
toSwapCurrencyStatus = toStatus,
providers = listOf(dexProvider),
amountToSwap = "1.0",
reduceBalanceBy = BigDecimal.ZERO,
)
// Then
assertThat(result).hasSize(1)
val state = result[dexProvider]
assertThat(state).isInstanceOf(SwapState.QuotesLoadedState::class.java)
val loaded = state as SwapState.QuotesLoadedState
assertThat(loaded.preparedSwapConfigState.balanceStatus)
.isInstanceOf(
com.tangem.feature.swap.domain.models.domain.SwapBalanceStatus.InsufficientAmount::class.java,
)
}
@Test
fun `should return non-null state for DEX provider when repository findBestQuote returns error`() = runTest {
@ -353,7 +352,7 @@ internal class SwapInteractorImplFindBestQuoteTest : SwapInteractorImplTestBase(
providerId = dexProvider.providerId,
rateType = any(),
)
} returns ExpressDataError.UnknownError.left()
} returns ExpressDataError.UnknownError().left()
// When
val result = sut.findBestQuote(
@ -362,7 +361,7 @@ internal class SwapInteractorImplFindBestQuoteTest : SwapInteractorImplTestBase(
providers = listOf(dexProvider),
amountToSwap = "1.0",
reduceBalanceBy = BigDecimal.ZERO,
txFeeSealedState = buildTxFeeSealedState(),
)
// Then — a SwapState is emitted for the provider (not an EmptyAmountState)
@ -430,7 +429,7 @@ internal class SwapInteractorImplFindBestQuoteTest : SwapInteractorImplTestBase(
providers = listOf(dexBridgeProvider),
amountToSwap = "1.0",
reduceBalanceBy = BigDecimal.ZERO,
txFeeSealedState = buildTxFeeSealedState(),
)
// Then
@ -499,7 +498,7 @@ internal class SwapInteractorImplFindBestQuoteTest : SwapInteractorImplTestBase(
providers = listOf(dexProvider),
amountToSwap = "1.0",
reduceBalanceBy = BigDecimal.ZERO,
txFeeSealedState = buildTxFeeSealedState(),
)
// Then
@ -508,85 +507,84 @@ internal class SwapInteractorImplFindBestQuoteTest : SwapInteractorImplTestBase(
}
@Test
fun `should return SwapError TooLargeSolanaTransactionError when tx bytes exceed threshold on Cold wallet`() =
runTest {
// Given — decode returns an oversized array; mock the Solana helper to preserve its size
mockkStatic(Base64::class)
every { Base64.decode(any<String>(), any()) } returns ByteArray(931)
io.mockk.mockkObject(SolanaTransactionHelper)
every {
SolanaTransactionHelper.removeSignaturesPlaceholders(any())
} returns ByteArray(931)
fun `Solana size guard no longer fires during findBestQuote — fee owned by selector`() = runTest {
// [REDACTED_TASK_KEY] Phase 4: findBestQuote no longer loads fees, so the Solana size guard
// (which lives inside DexSwapFeeCalculator) is not reached here. The guard now fires
// only when the fee selector calls loadSwapFee. See DexSwapFeeCalculatorTest for the
// size-guard assertion; here we only verify findBestQuote completes without surfacing
// it as a SwapError.
mockkStatic(Base64::class)
every { Base64.decode(any<String>(), any()) } returns ByteArray(931)
io.mockk.mockkObject(SolanaTransactionHelper)
every {
SolanaTransactionHelper.removeSignaturesPlaceholders(any())
} returns ByteArray(931)
val dexProvider = buildSwapProvider(ExchangeProviderType.DEX)
val coldWallet = mockk<UserWallet.Cold>(relaxed = true)
val fromStatus = buildSwapCurrencyStatus(
networkRawId = solanaNetwork,
isCoin = true,
amount = BigDecimal("10"),
).let { status ->
// replace the relaxed UserWallet mock with a real Cold mock so `is UserWallet.Cold` is true
SwapCurrencyStatus(
userWallet = coldWallet,
status = status.status,
account = status.account,
)
}
val toStatus = buildSwapCurrencyStatus(networkRawId = solanaNetwork)
val quoteModel = buildQuoteModel()
val solanaSwapData = buildSwapDataModelDex(txData = "oversized==")
coEvery {
repository.findBestQuote(
userWallet = any(),
fromContractAddress = any(),
fromNetwork = solanaNetwork,
toContractAddress = any(),
toNetwork = any(),
fromAmount = any(),
fromDecimals = any(),
toDecimals = any(),
providerId = dexProvider.providerId,
rateType = any(),
)
} returns quoteModel.right()
coEvery {
repository.getExchangeData(
userWallet = any(),
fromContractAddress = any(),
fromNetwork = any(),
toContractAddress = any(),
fromAddress = any(),
toNetwork = any(),
fromAmount = any(),
fromDecimals = any(),
toDecimals = any(),
providerId = dexProvider.providerId,
rateType = any(),
toAddress = any(),
expressOperationType = any(),
refundAddress = any(),
)
} returns solanaSwapData.right()
// When
val result = sut.findBestQuote(
fromSwapCurrencyStatus = fromStatus,
toSwapCurrencyStatus = toStatus,
providers = listOf(dexProvider),
amountToSwap = "1.0",
reduceBalanceBy = BigDecimal.ZERO,
txFeeSealedState = buildTxFeeSealedState(),
val dexProvider = buildSwapProvider(ExchangeProviderType.DEX)
val coldWallet = mockk<UserWallet.Cold>(relaxed = true)
val fromStatus = buildSwapCurrencyStatus(
networkRawId = solanaNetwork,
isCoin = true,
amount = BigDecimal("10"),
).let { status ->
SwapCurrencyStatus(
userWallet = coldWallet,
status = status.status,
account = status.account,
)
// Then — oversized Solana tx on Cold wallet produces SwapError with TooLargeSolanaTransactionError
assertThat(result).hasSize(1)
val state = result[dexProvider]
assertThat(state).isInstanceOf(SwapState.SwapError::class.java)
val swapError = state as SwapState.SwapError
assertThat(swapError.error).isEqualTo(ExpressDataError.TooLargeSolanaTransactionError)
}
val toStatus = buildSwapCurrencyStatus(networkRawId = solanaNetwork)
val quoteModel = buildQuoteModel()
val solanaSwapData = buildSwapDataModelDex(txData = "oversized==")
coEvery {
repository.findBestQuote(
userWallet = any(),
fromContractAddress = any(),
fromNetwork = solanaNetwork,
toContractAddress = any(),
toNetwork = any(),
fromAmount = any(),
fromDecimals = any(),
toDecimals = any(),
providerId = dexProvider.providerId,
rateType = any(),
)
} returns quoteModel.right()
coEvery {
repository.getExchangeData(
userWallet = any(),
fromContractAddress = any(),
fromNetwork = any(),
toContractAddress = any(),
fromAddress = any(),
toNetwork = any(),
fromAmount = any(),
fromDecimals = any(),
toDecimals = any(),
providerId = dexProvider.providerId,
rateType = any(),
toAddress = any(),
expressOperationType = any(),
refundAddress = any(),
)
} returns solanaSwapData.right()
// When
val result = sut.findBestQuote(
fromSwapCurrencyStatus = fromStatus,
toSwapCurrencyStatus = toStatus,
providers = listOf(dexProvider),
amountToSwap = "1.0",
reduceBalanceBy = BigDecimal.ZERO,
)
// Then — under Phase 4, findBestQuote returns QuotesLoadedState; size guard is deferred
assertThat(result).hasSize(1)
val state = result[dexProvider]
assertThat(state).isInstanceOf(SwapState.QuotesLoadedState::class.java)
}
@Test
fun `should produce non-empty state via Solana path when balance insufficient`() = runTest {
@ -622,7 +620,7 @@ internal class SwapInteractorImplFindBestQuoteTest : SwapInteractorImplTestBase(
providers = listOf(dexProvider),
amountToSwap = "1000.0",
reduceBalanceBy = BigDecimal.ZERO,
txFeeSealedState = buildTxFeeSealedState(),
)
// Then
@ -668,7 +666,7 @@ internal class SwapInteractorImplFindBestQuoteTest : SwapInteractorImplTestBase(
providers = listOf(cexProvider),
amountToSwap = "1.0",
reduceBalanceBy = BigDecimal.ZERO,
txFeeSealedState = buildTxFeeSealedState(),
)
// Then
@ -711,7 +709,7 @@ internal class SwapInteractorImplFindBestQuoteTest : SwapInteractorImplTestBase(
providers = listOf(cexProvider),
amountToSwap = "1.0",
reduceBalanceBy = BigDecimal.ZERO,
txFeeSealedState = buildTxFeeSealedState(),
)
// Then
@ -793,7 +791,7 @@ internal class SwapInteractorImplFindBestQuoteTest : SwapInteractorImplTestBase(
providers = listOf(dexProvider, cexProvider),
amountToSwap = "1.0",
reduceBalanceBy = BigDecimal.ZERO,
txFeeSealedState = buildTxFeeSealedState(),
)
// Then — both providers have an entry
@ -862,7 +860,7 @@ internal class SwapInteractorImplFindBestQuoteTest : SwapInteractorImplTestBase(
providers = listOf(dexProvider, cexProvider, dexBridgeProvider),
amountToSwap = "1.0",
reduceBalanceBy = BigDecimal.ZERO,
txFeeSealedState = buildTxFeeSealedState(),
)
// Then — all three providers are dispatched and each has an entry

View file

@ -1,86 +0,0 @@
package com.tangem.feature.swap.domain
import com.google.common.truth.Truth.assertThat
import com.tangem.blockchain.common.Blockchain
import com.tangem.blockchainsdk.utils.toNetworkId
import com.tangem.domain.models.currency.CryptoCurrency
import com.tangem.domain.models.network.Network
import io.mockk.coEvery
import io.mockk.coVerify
import io.mockk.every
import io.mockk.mockk
import kotlinx.coroutines.test.runTest
import org.junit.jupiter.api.Test
import org.junit.jupiter.api.TestInstance
/**
* Tests for [SwapInteractorImpl.getNativeToken].
*
* Behavior:
* - Look up cached portfolio coins for the user wallet via [MultiWalletCryptoCurrenciesSupplier].
* - Return the coin matching the target network (by `id` and `derivationPath`).
* - If supplier returns null or no match fall back to [CurrenciesRepository.createCoinCurrency].
*/
@TestInstance(TestInstance.Lifecycle.PER_CLASS)
internal class SwapInteractorImplGetNativeTokenTest : SwapInteractorImplTestBase() {
private val ethNetwork = Blockchain.Ethereum.toNetworkId()
@Test
fun `should return a Coin from the supplier whose network matches the target`() = runTest {
// Given — a single matching coin in the supplier
val fromStatus = buildSwapCurrencyStatus(networkRawId = ethNetwork, isCoin = true)
val targetNetwork = fromStatus.currency.network
val matchingCoin = mockk<CryptoCurrency.Coin>(relaxed = true) {
every { network } returns targetNetwork
}
coEvery { multiWalletCryptoCurrenciesSupplier.getSyncOrNull(any()) } returns setOf(matchingCoin)
// When
val result = sut.getNativeToken(fromStatus)
// Then
assertThat(result).isSameInstanceAs(matchingCoin)
}
@Test
fun `should fall back to createCoinCurrency when supplier returns null`() = runTest {
// Given
val fromStatus = buildSwapCurrencyStatus(networkRawId = ethNetwork, isCoin = true)
val createdCoin = buildCoinCurrency(networkRawId = ethNetwork)
coEvery { multiWalletCryptoCurrenciesSupplier.getSyncOrNull(any()) } returns null
coEvery { currenciesRepository.createCoinCurrency(any()) } returns createdCoin
// When
val result = sut.getNativeToken(fromStatus)
// Then
assertThat(result).isSameInstanceAs(createdCoin)
coVerify(exactly = 1) { currenciesRepository.createCoinCurrency(any()) }
}
@Test
fun `should fall back to createCoinCurrency when no matching coin is in the supplier's list`() = runTest {
// Given — all returned coins are for a different network
val fromStatus = buildSwapCurrencyStatus(networkRawId = ethNetwork, isCoin = true)
val unrelatedCoin = mockk<CryptoCurrency.Coin>(relaxed = true) {
every { network } returns mockk<Network>(relaxed = true) {
every { id } returns mockk(relaxed = true)
every { derivationPath } returns Network.DerivationPath.None
}
}
val createdCoin = buildCoinCurrency(networkRawId = ethNetwork)
coEvery { multiWalletCryptoCurrenciesSupplier.getSyncOrNull(any()) } returns setOf(unrelatedCoin)
coEvery { currenciesRepository.createCoinCurrency(any()) } returns createdCoin
// When
val result = sut.getNativeToken(fromStatus)
// Then
assertThat(result).isSameInstanceAs(createdCoin)
}
}

View file

@ -0,0 +1,159 @@
package com.tangem.feature.swap.domain
import arrow.core.right
import com.google.common.truth.Truth.assertThat
import com.tangem.blockchain.common.Blockchain
import com.tangem.blockchainsdk.utils.toNetworkId
import com.tangem.domain.tokens.model.FeePaidCurrency
import com.tangem.domain.tokens.model.warnings.CryptoCurrencyCheck
import com.tangem.domain.transaction.models.AllowanceInfo
import com.tangem.feature.swap.domain.models.SwapAmount
import com.tangem.feature.swap.domain.models.domain.ExchangeProviderType
import com.tangem.feature.swap.domain.models.domain.ExpressTransactionModel
import com.tangem.feature.swap.domain.models.domain.SwapBalanceStatus
import com.tangem.feature.swap.domain.models.domain.SwapDataModel
import com.tangem.feature.swap.domain.models.ui.SwapState
import io.mockk.coEvery
import io.mockk.coVerify
import kotlinx.coroutines.test.runTest
import org.junit.jupiter.api.BeforeEach
import org.junit.jupiter.api.Test
import org.junit.jupiter.api.TestInstance
import java.math.BigDecimal
import java.math.BigInteger
/**
* Tests for `loadDexSwapDataNoFee` the replacement for the legacy `loadDexSwapData`.
*
* Verifies:
* - `dexSwapFeeCalculator.calculate` is NEVER called during quote loading (fee is owned by
* the fee selector now).
* - The returned `preparedSwapConfigState.balanceStatus` is [SwapBalanceStatus.Pending].
* - `swapDataModel` is populated from the Express response so `applySwapFee` (and
* `FeeSelectorRepository.loadFeeExtended`) can consume it later.
*/
@TestInstance(TestInstance.Lifecycle.PER_CLASS)
internal class SwapInteractorImplLoadDexSwapDataNoFeeTest : SwapInteractorImplTestBase() {
private val ethNetwork = Blockchain.Ethereum.toNetworkId()
@BeforeEach
fun setup() {
coEvery { currenciesRepository.getFeePaidCurrency(any(), any()) } returns FeePaidCurrency.Coin
coEvery { walletManagersFacade.getNativeTokenBalance(any(), any(), any()) } returns BigDecimal("10")
coEvery {
getCurrencyCheckUseCase.invoke(
userWalletId = any(),
currencyStatus = any(),
feeCurrencyStatus = any(),
amount = any(),
fee = any(),
feeCurrencyBalanceAfterTransaction = any(),
recipientAddress = any(),
)
} returns CryptoCurrencyCheck(
dustValue = null,
reserveAmount = null,
minimumSendAmount = null,
existentialDeposit = null,
utxoAmountLimit = null,
isAccountFunded = true,
rentWarning = null,
isMemoRequired = false,
)
coEvery {
validateTransactionUseCase.invoke(
amount = any(),
fee = any(),
memo = any(),
destination = any(),
userWalletId = any(),
network = any(),
)
} returns Unit.right()
coEvery { quotesRepository.getMultiQuoteSyncOrNull(any()) } returns emptySet()
coEvery { multiQuoteStatusFetcher.invoke(any()) } returns Unit.right()
coEvery { getFeePaidCryptoCurrencyStatusSyncUseCase.invoke(any(), any()) } returns null.right()
coEvery { currenciesRepository.createCoinCurrency(any()) } returns buildCoinCurrency()
coEvery {
getAllowanceInfoUseCase.invoke(any(), any(), any(), any())
} returns (AllowanceInfo.Enough(allowance = BigDecimal("100")) as AllowanceInfo).right()
}
@Test
fun `DEX findBestQuote returns QuotesLoadedState without invoking DexSwapFeeCalculator`() = runTest {
val dexProvider = buildSwapProvider(ExchangeProviderType.DEX)
val from = buildSwapCurrencyStatus(networkRawId = ethNetwork, isCoin = true, amount = BigDecimal("10"))
val to = buildSwapCurrencyStatus(networkRawId = ethNetwork)
val quoteModel = buildQuoteModel(allowanceContract = null)
val swapDataModel = SwapDataModel(
toTokenAmount = SwapAmount(BigDecimal("0.5"), 18),
transaction = ExpressTransactionModel.DEX(
fromAmount = SwapAmount(BigDecimal("1.0"), 18),
toAmount = SwapAmount(BigDecimal("0.5"), 18),
txValue = "1000000000000000000",
txId = "tx-id",
txTo = "0xToAddress",
txExtraId = null,
txFrom = "0xFromAddress",
txData = "0xdata",
otherNativeFeeWei = null,
gas = BigInteger.valueOf(21_000L),
),
)
coEvery {
repository.findBestQuote(
userWallet = any(),
fromContractAddress = any(),
fromNetwork = any(),
toContractAddress = any(),
toNetwork = any(),
fromAmount = any(),
fromDecimals = any(),
toDecimals = any(),
providerId = dexProvider.providerId,
rateType = any(),
)
} returns quoteModel.right()
coEvery {
repository.getExchangeData(
userWallet = any(),
fromContractAddress = any(),
fromNetwork = any(),
toContractAddress = any(),
fromAddress = any(),
toNetwork = any(),
fromAmount = any(),
fromDecimals = any(),
toDecimals = any(),
providerId = dexProvider.providerId,
rateType = any(),
toAddress = any(),
expressOperationType = any(),
refundAddress = any(),
)
} returns swapDataModel.right()
val result = sut.findBestQuote(
fromSwapCurrencyStatus = from,
toSwapCurrencyStatus = to,
providers = listOf(dexProvider),
amountToSwap = "1.0",
reduceBalanceBy = BigDecimal.ZERO,
)
assertThat(result).hasSize(1)
val state = result[dexProvider]
assertThat(state).isInstanceOf(SwapState.QuotesLoadedState::class.java)
val quotesState = state as SwapState.QuotesLoadedState
// Fee not computed yet — balanceStatus is Pending until applySwapFee patches the state.
assertThat(quotesState.preparedSwapConfigState.balanceStatus)
.isInstanceOf(SwapBalanceStatus.Pending::class.java)
// swapDataModel is propagated so the fee selector can later call loadSwapFee with it.
assertThat(quotesState.swapDataModel).isEqualTo(swapDataModel)
// Fee calculator must not be invoked during quote loading.
coVerify(exactly = 0) { dexSwapFeeCalculator.calculate(any(), any(), any()) }
}
}

View file

@ -1,441 +0,0 @@
package com.tangem.feature.swap.domain
import arrow.core.left
import arrow.core.right
import com.google.common.truth.Truth.assertThat
import com.tangem.blockchain.common.Blockchain
import com.tangem.blockchainsdk.utils.toNetworkId
import com.tangem.domain.models.currency.CryptoCurrencyStatus
import com.tangem.domain.transaction.error.GetFeeError
import com.tangem.domain.transaction.models.TransactionFeeExtended
import com.tangem.feature.swap.domain.models.ExpressDataError
import com.tangem.feature.swap.domain.models.domain.ExchangeProviderType
import io.mockk.coEvery
import io.mockk.coVerify
import io.mockk.mockk
import io.mockk.slot
import kotlinx.coroutines.test.runTest
import org.junit.jupiter.api.Nested
import org.junit.jupiter.api.Test
import org.junit.jupiter.api.TestInstance
import java.math.BigDecimal
/**
* Tests for [SwapInteractorImpl.loadFeeForSwapTransaction] (both overloads).
*
* Overload 1 (returns [Either<GetFeeError, TransactionFeeExtended>]):
* - DEX / DEX_BRIDGE always GaslessError.NetworkIsNotSupported
* - CEX + zero or unparseable amount UnknownError
* - CEX + selectedFeeToken != null delegates to [estimateFeeForTokenUseCase]
* - CEX + selectedFeeToken == null delegates to [estimateFeeForGaslessTxUseCase]
*
* Overload 2 (returns [Either<GetFeeError, TransactionFee>]):
* - DEX / DEX_BRIDGE + zero amount UnknownError
* - DEX / DEX_BRIDGE + getExchangeData error UnknownError
* - CEX + zero amount UnknownError
* - CEX + non-zero amount delegates to [estimateFeeUseCase]
*/
@TestInstance(TestInstance.Lifecycle.PER_CLASS)
internal class SwapInteractorImplLoadFeeTest : SwapInteractorImplTestBase() {
private val ethNetwork = Blockchain.Ethereum.toNetworkId()
private val btcNetwork = Blockchain.Bitcoin.toNetworkId()
// -------------------------------------------------------------------------
// Overload 1
// -------------------------------------------------------------------------
@Nested
inner class `overload 1 — CEX and token fee paths` {
@Test
fun `should return Left GaslessError for DEX provider`() = runTest {
// Given
val dexProvider = buildSwapProvider(ExchangeProviderType.DEX)
val fromStatus = buildSwapCurrencyStatus(networkRawId = ethNetwork)
// When
val result = sut.loadFeeForSwapTransaction(
fromSwapCurrencyStatus = fromStatus,
amount = "1.0",
reduceBalanceBy = BigDecimal.ZERO,
provider = dexProvider,
selectedFeeToken = null,
)
// Then
assertThat(result.isLeft()).isTrue()
result.onLeft { error ->
assertThat(error).isInstanceOf(GetFeeError.GaslessError.NetworkIsNotSupported::class.java)
}
}
@Test
fun `should return Left GaslessError for DEX_BRIDGE provider`() = runTest {
// Given
val dexBridgeProvider = buildSwapProvider(ExchangeProviderType.DEX_BRIDGE)
val fromStatus = buildSwapCurrencyStatus(networkRawId = ethNetwork)
// When
val result = sut.loadFeeForSwapTransaction(
fromSwapCurrencyStatus = fromStatus,
amount = "1.0",
reduceBalanceBy = BigDecimal.ZERO,
provider = dexBridgeProvider,
selectedFeeToken = null,
)
// Then
assertThat(result.isLeft()).isTrue()
result.onLeft { error ->
assertThat(error).isInstanceOf(GetFeeError.GaslessError.NetworkIsNotSupported::class.java)
}
}
@Test
fun `should return Left UnknownError for CEX provider when amount is zero`() = runTest {
// Given
val cexProvider = buildSwapProvider(ExchangeProviderType.CEX)
val fromStatus = buildSwapCurrencyStatus(networkRawId = ethNetwork)
// When
val result = sut.loadFeeForSwapTransaction(
fromSwapCurrencyStatus = fromStatus,
amount = "0",
reduceBalanceBy = BigDecimal.ZERO,
provider = cexProvider,
selectedFeeToken = null,
)
// Then
assertThat(result.isLeft()).isTrue()
result.onLeft { error ->
assertThat(error).isInstanceOf(GetFeeError.UnknownError::class.java)
}
}
@Test
fun `should return Left UnknownError for CEX provider when amount is invalid string`() = runTest {
// Given
val cexProvider = buildSwapProvider(ExchangeProviderType.CEX)
val fromStatus = buildSwapCurrencyStatus(networkRawId = ethNetwork)
// When
val result = sut.loadFeeForSwapTransaction(
fromSwapCurrencyStatus = fromStatus,
amount = "not-a-decimal",
reduceBalanceBy = BigDecimal.ZERO,
provider = cexProvider,
selectedFeeToken = null,
)
// Then
assertThat(result.isLeft()).isTrue()
result.onLeft { error ->
assertThat(error).isInstanceOf(GetFeeError.UnknownError::class.java)
}
}
@Test
fun `should delegate to estimateFeeForTokenUseCase when CEX provider has non-null selectedFeeToken`() =
runTest {
// Given
val cexProvider = buildSwapProvider(ExchangeProviderType.CEX)
val fromStatus = buildSwapCurrencyStatus(networkRawId = ethNetwork)
val feeTokenStatus = mockk<CryptoCurrencyStatus>(relaxed = true)
val expectedFeeExtended = mockk<TransactionFeeExtended>(relaxed = true)
coEvery {
estimateFeeForTokenUseCase.invoke(
userWallet = any(),
feeTokenCurrencyStatus = feeTokenStatus,
sendingTokenCurrencyStatus = any(),
amount = any(),
)
} returns expectedFeeExtended.right()
// When
val result = sut.loadFeeForSwapTransaction(
fromSwapCurrencyStatus = fromStatus,
amount = "1.5",
reduceBalanceBy = BigDecimal.ZERO,
provider = cexProvider,
selectedFeeToken = feeTokenStatus,
)
// Then
assertThat(result.isRight()).isTrue()
coVerify(exactly = 1) {
estimateFeeForTokenUseCase.invoke(
userWallet = any(),
feeTokenCurrencyStatus = feeTokenStatus,
sendingTokenCurrencyStatus = any(),
amount = BigDecimal("1.5"),
)
}
}
@Test
fun `should pass positive non-NaN amount to estimateFeeForGaslessTxUseCase for CEX with tiny nonzero amount and null selectedFeeToken`() =
runTest {
// Given — tiny but nonzero amount; null selectedFeeToken routes to estimateFeeForGaslessTxUseCase
val cexProvider = buildSwapProvider(ExchangeProviderType.CEX)
val fromStatus = buildSwapCurrencyStatus(networkRawId = ethNetwork)
val feeExtended = mockk<TransactionFeeExtended>(relaxed = true)
val capturedAmount = slot<BigDecimal>()
coEvery {
estimateFeeForGaslessTxUseCase.invoke(
amount = capture(capturedAmount),
userWallet = any(),
sendingTokenCurrencyStatus = any(),
)
} returns feeExtended.right()
// When
sut.loadFeeForSwapTransaction(
fromSwapCurrencyStatus = fromStatus,
amount = "0.000001",
reduceBalanceBy = BigDecimal.ZERO,
provider = cexProvider,
selectedFeeToken = null,
)
// Then — captured amount is positive, finite, non-NaN
assertThat(capturedAmount.captured).isNotNull()
assertThat(capturedAmount.captured.signum()).isGreaterThan(0)
assertThat(capturedAmount.captured.toDouble().isNaN()).isFalse()
assertThat(capturedAmount.captured.toDouble().isInfinite()).isFalse()
// verify estimateFeeForGaslessTxUseCase was called with the exact parsed amount
coVerify(exactly = 1) {
estimateFeeForGaslessTxUseCase.invoke(
amount = BigDecimal("0.000001"),
userWallet = any(),
sendingTokenCurrencyStatus = any(),
)
}
}
@Test
fun `should delegate to estimateFeeForGaslessTxUseCase when CEX provider has null selectedFeeToken`() =
runTest {
// Given
val cexProvider = buildSwapProvider(ExchangeProviderType.CEX)
val fromStatus = buildSwapCurrencyStatus(networkRawId = ethNetwork)
val expectedFeeExtended = mockk<TransactionFeeExtended>(relaxed = true)
coEvery {
estimateFeeForGaslessTxUseCase.invoke(
amount = any(),
userWallet = any(),
sendingTokenCurrencyStatus = any(),
)
} returns expectedFeeExtended.right()
// When
val result = sut.loadFeeForSwapTransaction(
fromSwapCurrencyStatus = fromStatus,
amount = "2.0",
reduceBalanceBy = BigDecimal.ZERO,
provider = cexProvider,
selectedFeeToken = null,
)
// Then
assertThat(result.isRight()).isTrue()
coVerify(exactly = 1) {
estimateFeeForGaslessTxUseCase.invoke(
amount = BigDecimal("2.0"),
userWallet = any(),
sendingTokenCurrencyStatus = any(),
)
}
}
}
// -------------------------------------------------------------------------
// Overload 2
// -------------------------------------------------------------------------
@Nested
inner class `overload 2 — DEX and CEX TransactionFee paths` {
@Test
fun `should return Left UnknownError for DEX when amount is zero`() = runTest {
// Given
val dexProvider = buildSwapProvider(ExchangeProviderType.DEX)
val fromStatus = buildSwapCurrencyStatus(networkRawId = ethNetwork)
val toStatus = buildSwapCurrencyStatus(networkRawId = btcNetwork)
// When
val result = sut.loadFeeForSwapTransaction(
fromSwapCurrencyStatus = fromStatus,
toSwapCurrencyStatus = toStatus,
amount = "0",
reduceBalanceBy = BigDecimal.ZERO,
provider = dexProvider,
)
// Then
assertThat(result.isLeft()).isTrue()
result.onLeft { error ->
assertThat(error).isInstanceOf(GetFeeError.UnknownError::class.java)
}
}
@Test
fun `should return Left UnknownError for DEX when getExchangeData returns error`() = runTest {
// Given
val dexProvider = buildSwapProvider(ExchangeProviderType.DEX)
val fromStatus = buildSwapCurrencyStatus(networkRawId = ethNetwork)
val toStatus = buildSwapCurrencyStatus(networkRawId = btcNetwork)
coEvery {
repository.getExchangeData(
userWallet = any(),
fromContractAddress = any(),
fromNetwork = any(),
toContractAddress = any(),
fromAddress = any(),
toNetwork = any(),
fromAmount = any(),
fromDecimals = any(),
toDecimals = any(),
providerId = any(),
rateType = any(),
toAddress = any(),
expressOperationType = any(),
refundAddress = any(),
)
} returns ExpressDataError.UnknownError.left()
// When
val result = sut.loadFeeForSwapTransaction(
fromSwapCurrencyStatus = fromStatus,
toSwapCurrencyStatus = toStatus,
amount = "1.0",
reduceBalanceBy = BigDecimal.ZERO,
provider = dexProvider,
)
// Then
assertThat(result.isLeft()).isTrue()
result.onLeft { error ->
assertThat(error).isInstanceOf(GetFeeError.UnknownError::class.java)
}
}
@Test
fun `should not call getExchangeData and return UnknownError for DEX when amount is zero`() = runTest {
// Given — zero amount must short-circuit before hitting repository
val dexProvider = buildSwapProvider(ExchangeProviderType.DEX)
val fromStatus = buildSwapCurrencyStatus(networkRawId = ethNetwork)
val toStatus = buildSwapCurrencyStatus(networkRawId = btcNetwork)
// When
val result = sut.loadFeeForSwapTransaction(
fromSwapCurrencyStatus = fromStatus,
toSwapCurrencyStatus = toStatus,
amount = "0",
reduceBalanceBy = BigDecimal.ZERO,
provider = dexProvider,
)
// Then
assertThat(result.isLeft()).isTrue()
result.onLeft { error -> assertThat(error).isInstanceOf(GetFeeError.UnknownError::class.java) }
coVerify(exactly = 0) {
repository.getExchangeData(
userWallet = any(),
fromContractAddress = any(),
fromNetwork = any(),
toContractAddress = any(),
fromAddress = any(),
toNetwork = any(),
fromAmount = any(),
fromDecimals = any(),
toDecimals = any(),
providerId = any(),
rateType = any(),
toAddress = any(),
expressOperationType = any(),
refundAddress = any(),
)
}
}
@Test
fun `should return Left UnknownError for DEX_BRIDGE when amount is zero`() = runTest {
// Given
val dexBridgeProvider = buildSwapProvider(ExchangeProviderType.DEX_BRIDGE)
val fromStatus = buildSwapCurrencyStatus(networkRawId = ethNetwork)
val toStatus = buildSwapCurrencyStatus(networkRawId = btcNetwork)
// When
val result = sut.loadFeeForSwapTransaction(
fromSwapCurrencyStatus = fromStatus,
toSwapCurrencyStatus = toStatus,
amount = "0",
reduceBalanceBy = BigDecimal.ZERO,
provider = dexBridgeProvider,
)
// Then
assertThat(result.isLeft()).isTrue()
}
@Test
fun `should return Left UnknownError for CEX when amount is zero`() = runTest {
// Given
val cexProvider = buildSwapProvider(ExchangeProviderType.CEX)
val fromStatus = buildSwapCurrencyStatus(networkRawId = ethNetwork)
val toStatus = buildSwapCurrencyStatus(networkRawId = btcNetwork)
// When
val result = sut.loadFeeForSwapTransaction(
fromSwapCurrencyStatus = fromStatus,
toSwapCurrencyStatus = toStatus,
amount = "0",
reduceBalanceBy = BigDecimal.ZERO,
provider = cexProvider,
)
// Then
assertThat(result.isLeft()).isTrue()
}
@Test
fun `should delegate to estimateFeeUseCase for CEX provider with non-zero amount`() = runTest {
// Given — return Left to avoid the patchTransactionFeeForSwap branch which requires concrete Fee types
val cexProvider = buildSwapProvider(ExchangeProviderType.CEX)
val fromStatus = buildSwapCurrencyStatus(networkRawId = ethNetwork)
val toStatus = buildSwapCurrencyStatus(networkRawId = btcNetwork)
coEvery {
estimateFeeUseCase.invoke(
amount = any(),
userWallet = any(),
cryptoCurrencyStatus = any(),
)
} returns GetFeeError.UnknownError.left()
// When
sut.loadFeeForSwapTransaction(
fromSwapCurrencyStatus = fromStatus,
toSwapCurrencyStatus = toStatus,
amount = "1.0",
reduceBalanceBy = BigDecimal.ZERO,
provider = cexProvider,
)
// Then
coVerify(exactly = 1) {
estimateFeeUseCase.invoke(
amount = BigDecimal("1.0"),
userWallet = any(),
cryptoCurrencyStatus = any(),
)
}
}
}
}

View file

@ -0,0 +1,602 @@
package com.tangem.feature.swap.domain
import arrow.core.left
import arrow.core.right
import com.google.common.truth.Truth.assertThat
import com.tangem.blockchain.common.Amount
import com.tangem.blockchain.common.Blockchain
import com.tangem.blockchain.common.transaction.Fee
import com.tangem.blockchain.common.transaction.TransactionFee
import com.tangem.blockchainsdk.utils.toNetworkId
import com.tangem.domain.models.currency.CryptoCurrency
import com.tangem.domain.models.currency.CryptoCurrencyStatus
import com.tangem.domain.tokens.model.FeePaidCurrency
import com.tangem.domain.transaction.error.GetFeeError
import com.tangem.domain.transaction.models.TransactionFeeExtended
import com.tangem.feature.swap.domain.fee.CexFeeResult
import com.tangem.feature.swap.domain.fee.DexFeeResult
import com.tangem.feature.swap.domain.fee.TransactionFeeResult
import com.tangem.feature.swap.domain.models.ExpressDataError
import com.tangem.feature.swap.domain.models.SwapAmount
import com.tangem.feature.swap.domain.models.domain.ExchangeProviderType
import com.tangem.feature.swap.domain.models.domain.ExpressTransactionModel
import com.tangem.feature.swap.domain.models.domain.SwapDataModel
import com.tangem.feature.swap.domain.models.ui.FeeBucket
import io.mockk.coEvery
import io.mockk.coVerify
import io.mockk.mockk
import kotlinx.coroutines.test.runTest
import org.junit.jupiter.api.BeforeEach
import org.junit.jupiter.api.Test
import org.junit.jupiter.api.TestInstance
import java.math.BigDecimal
import java.math.BigInteger
/**
* Tests for [SwapInteractorImpl.loadSwapFee] ([REDACTED_TASK_KEY] Phase 3).
*
* Exercises the unified fee API and verifies the four strategy branches:
* - DEX-EVM: delegates to `DexSwapFeeCalculator` and returns `SwapFee` with `otherNativeFee=0`.
* - DEX-Solana: same, no gas patch.
* - DEX bridge with `otherNativeFee > 0`: propagated through `SwapFee.otherNativeFee`.
* - CEX gasless-native (selectedFeeToken == null, gasless picks native).
* - CEX gasless-token (selectedFeeToken == null, gasless picks token).
* - CEX token-explicit (selectedFeeToken != null).
* - DEX with swapData == null `Left(GetFeeError.UnknownError)`.
* - Zero amount matches existing CEX/DEX paths (returns Left UnknownError).
*
* The DEX/CEX calculators themselves are mocked here their internals are covered by
* [com.tangem.feature.swap.domain.fee.DexSwapFeeCalculatorTest] and
* [com.tangem.feature.swap.domain.fee.CexSwapFeeCalculatorTest].
*/
@TestInstance(TestInstance.Lifecycle.PER_CLASS)
internal class SwapInteractorImplLoadSwapFeeTest : SwapInteractorImplTestBase() {
private val ethNetwork = Blockchain.Ethereum.toNetworkId()
private val solanaNetwork = Blockchain.Solana.toNetworkId()
private val nativeFeeTokenStatus = mockk<CryptoCurrencyStatus>(relaxed = true)
@BeforeEach
fun setup() {
// `loadSwapFee` resolves the default `selectedFeeToken` via the fee-paid use case when
// the caller passes null. Stub a concrete CryptoCurrencyStatus so the assertion is stable.
coEvery {
getFeePaidCryptoCurrencyStatusSyncUseCase.invoke(any(), any())
} returns nativeFeeTokenStatus.right()
}
// -------------------------------------------------------------------------
// DEX branch
// -------------------------------------------------------------------------
@Test
fun `DEX EVM delegates to DexSwapFeeCalculator and returns SwapFee with zero otherNativeFee`() = runTest {
val fromStatus = buildSwapCurrencyStatus(networkRawId = ethNetwork, isCoin = true)
val toStatus = buildSwapCurrencyStatus(networkRawId = ethNetwork, isCoin = true)
val transaction = buildDexTransaction(otherNativeFeeWei = null)
val swapData = SwapDataModel(
toTokenAmount = SwapAmount(BigDecimal("0.5"), 18),
transaction = transaction,
)
val rawFee = TransactionFee.Single(normal = mockk<Fee.Common>(relaxed = true))
coEvery {
dexSwapFeeCalculator.calculate(any(), any(), any())
} returns DexFeeResult(
transactionFee = TransactionFeeResult.Loaded(rawFee),
otherNativeFee = BigDecimal.ZERO,
gas = BigInteger.valueOf(21_000L),
).right()
val result = sut.loadSwapFee(
provider = buildSwapProvider(ExchangeProviderType.DEX),
fromStatus = fromStatus,
toStatus = toStatus,
amount = SwapAmount(BigDecimal.ONE, 18),
swapData = swapData,
selectedFeeToken = null,
)
assertThat(result.isRight()).isTrue()
result.onRight { swapFee ->
assertThat(swapFee.otherNativeFee).isEqualTo(BigDecimal.ZERO)
assertThat(swapFee.transactionFeeResult).isInstanceOf(TransactionFeeResult.Loaded::class.java)
assertThat(swapFee.feeBucket).isEqualTo(FeeBucket.MARKET)
assertThat(swapFee.selectedFeeToken).isSameInstanceAs(nativeFeeTokenStatus)
}
coVerify(exactly = 1) {
dexSwapFeeCalculator.calculate(fromStatus, transaction, null)
}
}
@Test
fun `DEX Solana delegates to DexSwapFeeCalculator and propagates the loaded fee without gas patch`() = runTest {
val fromStatus = buildSwapCurrencyStatus(networkRawId = solanaNetwork, isCoin = true, decimals = 9)
val toStatus = buildSwapCurrencyStatus(networkRawId = solanaNetwork, isCoin = true, decimals = 9)
val transaction = buildDexTransaction()
val swapData = SwapDataModel(
toTokenAmount = SwapAmount(BigDecimal("0.5"), 9),
transaction = transaction,
)
val solanaFee = TransactionFee.Single(
normal = Fee.Common(
Amount(currencySymbol = "SOL", value = BigDecimal("0.005"), decimals = 9),
),
)
coEvery {
dexSwapFeeCalculator.calculate(any(), any(), any())
} returns DexFeeResult(
transactionFee = TransactionFeeResult.Loaded(solanaFee),
otherNativeFee = BigDecimal.ZERO,
gas = null,
).right()
val result = sut.loadSwapFee(
provider = buildSwapProvider(ExchangeProviderType.DEX),
fromStatus = fromStatus,
toStatus = toStatus,
amount = SwapAmount(BigDecimal.ONE, 9),
swapData = swapData,
selectedFeeToken = null,
)
assertThat(result.isRight()).isTrue()
result.onRight { swapFee ->
assertThat(swapFee.otherNativeFee).isEqualTo(BigDecimal.ZERO)
assertThat(swapFee.fee).isEqualTo(solanaFee.normal)
}
}
@Test
fun `DEX_BRIDGE propagates otherNativeFee from DexFeeResult to SwapFee`() = runTest {
val fromStatus = buildSwapCurrencyStatus(networkRawId = ethNetwork, isCoin = true)
val toStatus = buildSwapCurrencyStatus(networkRawId = ethNetwork, isCoin = true)
val transaction = buildDexTransaction(otherNativeFeeWei = BigDecimal("500000000000000000"))
val swapData = SwapDataModel(
toTokenAmount = SwapAmount(BigDecimal("0.5"), 18),
transaction = transaction,
)
coEvery {
dexSwapFeeCalculator.calculate(any(), any(), any())
} returns DexFeeResult(
transactionFee = TransactionFeeResult.Loaded(
TransactionFee.Single(normal = mockk<Fee.Common>(relaxed = true)),
),
otherNativeFee = BigDecimal("0.5"),
gas = BigInteger.valueOf(21_000L),
).right()
val result = sut.loadSwapFee(
provider = buildSwapProvider(ExchangeProviderType.DEX_BRIDGE),
fromStatus = fromStatus,
toStatus = toStatus,
amount = SwapAmount(BigDecimal.ONE, 18),
swapData = swapData,
selectedFeeToken = null,
)
assertThat(result.isRight()).isTrue()
result.onRight { swapFee ->
assertThat(swapFee.otherNativeFee).isEquivalentAccordingToCompareTo(BigDecimal("0.5"))
}
}
@Test
fun `DEX with swapData == null returns Left UnknownError without calling calculator`() = runTest {
val fromStatus = buildSwapCurrencyStatus(networkRawId = ethNetwork, isCoin = true)
val toStatus = buildSwapCurrencyStatus(networkRawId = ethNetwork, isCoin = true)
val result = sut.loadSwapFee(
provider = buildSwapProvider(ExchangeProviderType.DEX),
fromStatus = fromStatus,
toStatus = toStatus,
amount = SwapAmount(BigDecimal.ONE, 18),
swapData = null,
selectedFeeToken = null,
)
assertThat(result.isLeft()).isTrue()
result.onLeft { error ->
assertThat(error).isInstanceOf(GetFeeError.UnknownError::class.java)
}
coVerify(exactly = 0) { dexSwapFeeCalculator.calculate(any(), any(), any()) }
}
@Test
fun `DEX_BRIDGE with swapData == null returns Left UnknownError`() = runTest {
val fromStatus = buildSwapCurrencyStatus(networkRawId = ethNetwork, isCoin = true)
val toStatus = buildSwapCurrencyStatus(networkRawId = ethNetwork, isCoin = true)
val result = sut.loadSwapFee(
provider = buildSwapProvider(ExchangeProviderType.DEX_BRIDGE),
fromStatus = fromStatus,
toStatus = toStatus,
amount = SwapAmount(BigDecimal.ONE, 18),
swapData = null,
selectedFeeToken = null,
)
assertThat(result.isLeft()).isTrue()
result.onLeft { error ->
assertThat(error).isInstanceOf(GetFeeError.UnknownError::class.java)
}
}
@Test
fun `DEX calculator Left ExpressDataError maps to Wrapped Left GetFeeError`() = runTest {
val fromStatus = buildSwapCurrencyStatus(networkRawId = ethNetwork, isCoin = true)
val toStatus = buildSwapCurrencyStatus(networkRawId = ethNetwork, isCoin = true)
val swapData = SwapDataModel(
toTokenAmount = SwapAmount(BigDecimal("0.5"), 18),
transaction = buildDexTransaction(),
)
coEvery {
dexSwapFeeCalculator.calculate(any(), any(), any())
} returns ExpressDataError.UnknownError().left()
val result = sut.loadSwapFee(
provider = buildSwapProvider(ExchangeProviderType.DEX),
fromStatus = fromStatus,
toStatus = toStatus,
amount = SwapAmount(BigDecimal.ONE, 18),
swapData = swapData,
selectedFeeToken = null,
)
assertThat(result.isLeft()).isTrue()
result.onLeft { error ->
assertThat(error).isInstanceOf(GetFeeError.DataError::class.java)
assertThat((error as? GetFeeError.DataError)?.cause).isInstanceOf(ExpressDataError.UnknownError::class.java)
}
}
// -------------------------------------------------------------------------
// CEX branch
// -------------------------------------------------------------------------
@Test
fun `CEX gasless-native delegates to CexSwapFeeCalculator and resolves native coin status`() = runTest {
val fromStatus = buildSwapCurrencyStatus(networkRawId = ethNetwork, isCoin = false)
val toStatus = buildSwapCurrencyStatus(networkRawId = ethNetwork, isCoin = true)
val extendedFee = mockk<TransactionFeeExtended>(relaxed = true) {
// Gasless picked native — feeTokenId points at the network's coin.
io.mockk.every { transactionFee } returns TransactionFee.Single(
normal = mockk<Fee.Common>(relaxed = true),
)
}
coEvery {
cexSwapFeeCalculator.calculate(any(), any(), any(), any())
} returns CexFeeResult(
transactionFee = TransactionFeeResult.LoadedExtended(extendedFee),
).right()
val result = sut.loadSwapFee(
provider = buildSwapProvider(ExchangeProviderType.CEX),
fromStatus = fromStatus,
toStatus = toStatus,
amount = SwapAmount(BigDecimal.ONE, 18),
swapData = null,
selectedFeeToken = null,
)
assertThat(result.isRight()).isTrue()
result.onRight { swapFee ->
assertThat(swapFee.transactionFeeResult).isInstanceOf(TransactionFeeResult.LoadedExtended::class.java)
assertThat(swapFee.selectedFeeToken).isSameInstanceAs(nativeFeeTokenStatus)
assertThat(swapFee.otherNativeFee).isEqualTo(BigDecimal.ZERO)
}
coVerify(exactly = 1) {
cexSwapFeeCalculator.calculate(
userWallet = fromStatus.userWallet,
fromSwapCurrencyStatus = fromStatus,
amount = BigDecimal.ONE,
selectedFeeToken = null,
)
}
}
@Test
fun `CEX gasless-token (null selectedFeeToken, gasless picks token) returns native coin as fee token by default`() =
runTest {
// The unified contract here is: when caller passes null, the impl resolves the
// native coin status via GetFeePaidCryptoCurrencyStatusSyncUseCase. The fact that
// gasless internally picked a token does not change the SwapFee.selectedFeeToken
// — that resolution is the caller's responsibility (it happens in Phase 4 when
// FeeSelectorRepository builds the call).
val fromStatus = buildSwapCurrencyStatus(networkRawId = ethNetwork, isCoin = false)
val toStatus = buildSwapCurrencyStatus(networkRawId = ethNetwork, isCoin = true)
val extendedFee = mockk<TransactionFeeExtended>(relaxed = true)
coEvery {
cexSwapFeeCalculator.calculate(any(), any(), any(), any())
} returns CexFeeResult(
transactionFee = TransactionFeeResult.LoadedExtended(extendedFee),
).right()
val result = sut.loadSwapFee(
provider = buildSwapProvider(ExchangeProviderType.CEX),
fromStatus = fromStatus,
toStatus = toStatus,
amount = SwapAmount(BigDecimal.ONE, 18),
swapData = null,
selectedFeeToken = null,
)
assertThat(result.isRight()).isTrue()
result.onRight { swapFee ->
assertThat(swapFee.selectedFeeToken).isSameInstanceAs(nativeFeeTokenStatus)
assertThat(swapFee.transactionFeeResult).isInstanceOf(TransactionFeeResult.LoadedExtended::class.java)
}
}
@Test
fun `CEX token-explicit propagates the provided selectedFeeToken into SwapFee`() = runTest {
val fromStatus = buildSwapCurrencyStatus(networkRawId = ethNetwork, isCoin = false)
val toStatus = buildSwapCurrencyStatus(networkRawId = ethNetwork, isCoin = true)
val explicitTokenStatus = mockk<CryptoCurrencyStatus>(relaxed = true) {
io.mockk.every { currency } returns mockk<CryptoCurrency.Token>(relaxed = true)
}
val extendedFee = mockk<TransactionFeeExtended>(relaxed = true)
coEvery {
cexSwapFeeCalculator.calculate(any(), any(), any(), any())
} returns CexFeeResult(
transactionFee = TransactionFeeResult.LoadedExtended(extendedFee),
).right()
val result = sut.loadSwapFee(
provider = buildSwapProvider(ExchangeProviderType.CEX),
fromStatus = fromStatus,
toStatus = toStatus,
amount = SwapAmount(BigDecimal.ONE, 18),
swapData = null,
selectedFeeToken = explicitTokenStatus,
)
assertThat(result.isRight()).isTrue()
result.onRight { swapFee ->
assertThat(swapFee.selectedFeeToken).isSameInstanceAs(explicitTokenStatus)
}
coVerify(exactly = 1) {
cexSwapFeeCalculator.calculate(
userWallet = fromStatus.userWallet,
fromSwapCurrencyStatus = fromStatus,
amount = BigDecimal.ONE,
selectedFeeToken = explicitTokenStatus,
)
}
}
@Test
fun `CEX explicit native selectedFeeToken returns SwapFee with Loaded fee result`() = runTest {
val fromStatus = buildSwapCurrencyStatus(networkRawId = ethNetwork, isCoin = true)
val toStatus = buildSwapCurrencyStatus(networkRawId = ethNetwork, isCoin = true)
val explicitNativeStatus = mockk<CryptoCurrencyStatus>(relaxed = true) {
io.mockk.every { currency } returns mockk<CryptoCurrency.Coin>(relaxed = true)
}
val rawFee = TransactionFee.Single(normal = mockk<Fee.Common>(relaxed = true))
coEvery {
cexSwapFeeCalculator.calculate(any(), any(), any(), any())
} returns CexFeeResult(
transactionFee = TransactionFeeResult.Loaded(rawFee),
).right()
val result = sut.loadSwapFee(
provider = buildSwapProvider(ExchangeProviderType.CEX),
fromStatus = fromStatus,
toStatus = toStatus,
amount = SwapAmount(BigDecimal.ONE, 18),
swapData = null,
selectedFeeToken = explicitNativeStatus,
)
assertThat(result.isRight()).isTrue()
result.onRight { swapFee ->
assertThat(swapFee.selectedFeeToken).isSameInstanceAs(explicitNativeStatus)
assertThat(swapFee.transactionFeeResult).isInstanceOf(TransactionFeeResult.Loaded::class.java)
}
}
@Test
fun `CEX calculator Left UnknownError propagates as Left UnknownError`() = runTest {
val fromStatus = buildSwapCurrencyStatus(networkRawId = ethNetwork, isCoin = true)
val toStatus = buildSwapCurrencyStatus(networkRawId = ethNetwork, isCoin = true)
coEvery {
cexSwapFeeCalculator.calculate(any(), any(), any(), any())
} returns GetFeeError.UnknownError.left()
val result = sut.loadSwapFee(
provider = buildSwapProvider(ExchangeProviderType.CEX),
fromStatus = fromStatus,
toStatus = toStatus,
amount = SwapAmount(BigDecimal.ONE, 18),
swapData = null,
selectedFeeToken = null,
)
assertThat(result.isLeft()).isTrue()
result.onLeft { error ->
assertThat(error).isInstanceOf(GetFeeError.UnknownError::class.java)
}
}
// -------------------------------------------------------------------------
// Zero-amount short-circuit
// -------------------------------------------------------------------------
@Test
fun `amount zero on CEX returns Left UnknownError without calling calculator`() = runTest {
val fromStatus = buildSwapCurrencyStatus(networkRawId = ethNetwork, isCoin = true)
val toStatus = buildSwapCurrencyStatus(networkRawId = ethNetwork, isCoin = true)
val result = sut.loadSwapFee(
provider = buildSwapProvider(ExchangeProviderType.CEX),
fromStatus = fromStatus,
toStatus = toStatus,
amount = SwapAmount(BigDecimal.ZERO, 18),
swapData = null,
selectedFeeToken = null,
)
assertThat(result.isLeft()).isTrue()
result.onLeft { error ->
assertThat(error).isInstanceOf(GetFeeError.UnknownError::class.java)
}
coVerify(exactly = 0) { cexSwapFeeCalculator.calculate(any(), any(), any(), any()) }
}
@Test
fun `amount zero on DEX returns Left UnknownError without calling calculator`() = runTest {
val fromStatus = buildSwapCurrencyStatus(networkRawId = ethNetwork, isCoin = true)
val toStatus = buildSwapCurrencyStatus(networkRawId = ethNetwork, isCoin = true)
val swapData = SwapDataModel(
toTokenAmount = SwapAmount(BigDecimal("0.5"), 18),
transaction = buildDexTransaction(),
)
val result = sut.loadSwapFee(
provider = buildSwapProvider(ExchangeProviderType.DEX),
fromStatus = fromStatus,
toStatus = toStatus,
amount = SwapAmount(BigDecimal.ZERO, 18),
swapData = swapData,
selectedFeeToken = null,
)
assertThat(result.isLeft()).isTrue()
result.onLeft { error ->
assertThat(error).isInstanceOf(GetFeeError.UnknownError::class.java)
}
coVerify(exactly = 0) { dexSwapFeeCalculator.calculate(any(), any(), any()) }
}
// -------------------------------------------------------------------------
// DEX with explicit selectedFeeToken (Token)
// -------------------------------------------------------------------------
@Test
fun `DEX with explicit token selectedFeeToken propagates it into SwapFee and calls calculator with that token`() =
runTest {
val fromStatus = buildSwapCurrencyStatus(networkRawId = ethNetwork, isCoin = false)
val toStatus = buildSwapCurrencyStatus(networkRawId = ethNetwork, isCoin = true)
val transaction = buildDexTransaction()
val swapData = SwapDataModel(
toTokenAmount = SwapAmount(BigDecimal("0.5"), 18),
transaction = transaction,
)
val explicitTokenStatus = mockk<CryptoCurrencyStatus>(relaxed = true) {
io.mockk.every { currency } returns mockk<CryptoCurrency.Token>(relaxed = true)
}
val rawFee = TransactionFee.Single(normal = mockk<Fee.Common>(relaxed = true))
coEvery {
dexSwapFeeCalculator.calculate(any(), any(), any())
} returns DexFeeResult(
transactionFee = TransactionFeeResult.Loaded(rawFee),
otherNativeFee = BigDecimal.ZERO,
gas = BigInteger.valueOf(21_000L),
).right()
val result = sut.loadSwapFee(
provider = buildSwapProvider(ExchangeProviderType.DEX),
fromStatus = fromStatus,
toStatus = toStatus,
amount = SwapAmount(BigDecimal.ONE, 18),
swapData = swapData,
selectedFeeToken = explicitTokenStatus,
)
assertThat(result.isRight()).isTrue()
result.onRight { swapFee ->
assertThat(swapFee.selectedFeeToken).isSameInstanceAs(explicitTokenStatus)
}
coVerify(exactly = 1) {
dexSwapFeeCalculator.calculate(fromStatus, transaction, explicitTokenStatus)
}
}
// -------------------------------------------------------------------------
// resolveNativeFeeTokenStatus failure path
// -------------------------------------------------------------------------
/**
* When selectedFeeToken is null AND getFeePaidCryptoCurrencyStatusSyncUseCase returns Right(null),
* the impl falls back to building a CryptoCurrencyStatus from scratch.
* If networkAddress is null on the fromStatus, the fallback returns null and
* loadSwapFee must return Left(UnknownError).
*
* This exercises the `resolveNativeFeeTokenStatus` fallback path in loadDexSwapFee.
*/
@Test
fun `DEX with null selectedFeeToken — resolveNativeFeeTokenStatus returns null when networkAddress is null`() =
runTest {
// Primary resolve: getFeePaidCryptoCurrencyStatusSyncUseCase returns Right(null)
// → triggers the fallback block in resolveNativeFeeTokenStatus
coEvery {
getFeePaidCryptoCurrencyStatusSyncUseCase.invoke(any(), any())
} returns null.right()
// The fallback path tries to build a CryptoCurrencyStatus.NoQuote/Loaded
// but requires networkAddress to be non-null. Stub it to null so the
// fallback's early-return fires → resolveNativeFeeTokenStatus returns null.
val fromStatusWithNullAddr = buildSwapCurrencyStatus(
networkRawId = ethNetwork,
isCoin = true,
)
io.mockk.every {
fromStatusWithNullAddr.status.value.networkAddress
} returns null
val toStatus = buildSwapCurrencyStatus(networkRawId = ethNetwork, isCoin = true)
val swapData = SwapDataModel(
toTokenAmount = SwapAmount(BigDecimal("0.5"), 18),
transaction = buildDexTransaction(),
)
coEvery { currenciesRepository.getFeePaidCurrency(any(), any()) } returns FeePaidCurrency.Coin
coEvery { currenciesRepository.createCoinCurrency(any()) } returns buildCoinCurrency()
coEvery { walletManagersFacade.getNativeTokenBalance(any(), any(), any()) } returns BigDecimal("1.0")
// Make the calculator succeed (so the failure comes from resolveNativeFeeTokenStatus).
// quotesRepository returns null → NoQuote path → networkAddress null → return@run null
coEvery { quotesRepository.getMultiQuoteSyncOrNull(any()) } returns null
coEvery {
dexSwapFeeCalculator.calculate(any(), any(), any())
} returns DexFeeResult(
transactionFee = TransactionFeeResult.Loaded(
TransactionFee.Single(normal = mockk<Fee.Common>(relaxed = true)),
),
otherNativeFee = BigDecimal.ZERO,
gas = BigInteger.valueOf(21_000L),
).right()
val result = sut.loadSwapFee(
provider = buildSwapProvider(ExchangeProviderType.DEX),
fromStatus = fromStatusWithNullAddr,
toStatus = toStatus,
amount = SwapAmount(BigDecimal.ONE, 18),
swapData = swapData,
selectedFeeToken = null,
)
// When resolveNativeFeeTokenStatus returns null → Left(UnknownError)
assertThat(result.isLeft()).isTrue()
result.onLeft { error ->
assertThat(error).isInstanceOf(GetFeeError.UnknownError::class.java)
}
}
// -------------------------------------------------------------------------
// Helpers
// -------------------------------------------------------------------------
private fun buildDexTransaction(
otherNativeFeeWei: BigDecimal? = null,
): ExpressTransactionModel.DEX = ExpressTransactionModel.DEX(
fromAmount = SwapAmount(BigDecimal.ONE, 18),
toAmount = SwapAmount(BigDecimal("0.5"), 18),
txValue = "1000000000000000",
txId = "tx-id",
txTo = "0xTo",
txExtraId = null,
txFrom = "0xFrom",
txData = "dGVzdA==",
otherNativeFeeWei = otherNativeFeeWei,
gas = BigInteger.valueOf(21_000L),
)
}

View file

@ -28,25 +28,23 @@ import com.tangem.domain.swap.models.SwapPairModel
import com.tangem.domain.swap.usecase.GetSwapPairUseCase
import com.tangem.domain.tokens.GetAssetRequirementsUseCase
import com.tangem.domain.tokens.GetCurrencyCheckUseCase
import com.tangem.domain.tokens.MultiWalletCryptoCurrenciesSupplier
import com.tangem.domain.tokens.repository.CurrenciesRepository
import com.tangem.domain.tokens.repository.CurrencyChecksRepository
import com.tangem.domain.transaction.usecase.*
import com.tangem.domain.transaction.usecase.gasless.CreateAndSendGaslessTransactionUseCase
import com.tangem.domain.transaction.usecase.gasless.EstimateFeeForGaslessTxUseCase
import com.tangem.domain.transaction.usecase.gasless.EstimateFeeForTokenUseCase
import com.tangem.domain.transaction.usecase.gasless.GetFeeForTokenUseCase
import com.tangem.domain.walletmanager.WalletManagersFacade
import com.tangem.feature.swap.domain.api.SwapRepository
import com.tangem.feature.swap.domain.fee.CexSwapFeeCalculator
import com.tangem.feature.swap.domain.fee.DexSwapFeeCalculator
import com.tangem.feature.swap.domain.fee.TransactionFeeResult
import com.tangem.feature.swap.domain.models.SwapAmount
import com.tangem.feature.swap.domain.models.domain.*
import com.tangem.feature.swap.domain.models.ui.AmountFormatter
import com.tangem.feature.swap.domain.models.ui.TxFee
import com.tangem.feature.swap.domain.models.ui.SwapFee
import io.mockk.clearAllMocks
import io.mockk.every
import io.mockk.mockk
import io.mockk.unmockkAll
import org.junit.jupiter.api.AfterAll
import org.junit.jupiter.api.AfterEach
import java.math.BigDecimal
@ -69,19 +67,12 @@ internal open class SwapInteractorImplTestBase {
protected val quotesRepository: QuotesRepository = mockk(relaxed = true)
protected val multiQuoteStatusFetcher: MultiQuoteStatusFetcher = mockk(relaxed = true)
protected val swapTransactionRepository: SwapTransactionRepository = mockk(relaxed = true)
private val currencyChecksRepository: CurrencyChecksRepository = mockk(relaxed = true)
protected val currencyChecksRepository: CurrencyChecksRepository = mockk(relaxed = true)
private val appCurrencyRepository: AppCurrencyRepository = mockk(relaxed = true)
protected val currenciesRepository: CurrenciesRepository = mockk(relaxed = true)
protected val multiWalletCryptoCurrenciesSupplier: MultiWalletCryptoCurrenciesSupplier = mockk(relaxed = true)
protected val validateTransactionUseCase: ValidateTransactionUseCase = mockk(relaxed = true)
protected val estimateFeeUseCase: EstimateFeeUseCase = mockk(relaxed = true)
protected val estimateFeeForTokenUseCase: EstimateFeeForTokenUseCase = mockk(relaxed = true)
protected val estimateFeeForGaslessTxUseCase: EstimateFeeForGaslessTxUseCase = mockk(relaxed = true)
private val getFeeForTokenUseCase: GetFeeForTokenUseCase = mockk(relaxed = true)
protected val createAndSendGaslessTransactionUseCase: CreateAndSendGaslessTransactionUseCase =
mockk(relaxed = true)
protected val getFeeUseCase: GetFeeUseCase = mockk(relaxed = true)
private val getEthSpecificFeeUseCase: GetEthSpecificFeeUseCase = mockk(relaxed = true)
protected val getCurrencyCheckUseCase: GetCurrencyCheckUseCase = mockk(relaxed = true)
protected val getAssetRequirementsUseCase: GetAssetRequirementsUseCase = mockk(relaxed = true)
protected val amountFormatter: AmountFormatter = mockk(relaxed = true)
@ -91,6 +82,8 @@ internal open class SwapInteractorImplTestBase {
protected val walletManagersFacade: WalletManagersFacade = mockk(relaxed = true)
protected val getAllowanceInfoUseCase: GetAllowanceInfoUseCase = mockk(relaxed = true)
protected val getSwapPairUseCase: GetSwapPairUseCase = mockk(relaxed = true)
protected val dexSwapFeeCalculator: DexSwapFeeCalculator = mockk(relaxed = true)
protected val cexSwapFeeCalculator: CexSwapFeeCalculator = mockk(relaxed = true)
// endregion
@ -110,15 +103,8 @@ internal open class SwapInteractorImplTestBase {
currencyChecksRepository = currencyChecksRepository,
appCurrencyRepository = appCurrencyRepository,
currenciesRepository = currenciesRepository,
multiWalletCryptoCurrenciesSupplier = multiWalletCryptoCurrenciesSupplier,
validateTransactionUseCase = validateTransactionUseCase,
estimateFeeUseCase = estimateFeeUseCase,
estimateFeeForTokenUseCase = estimateFeeForTokenUseCase,
estimateFeeForGaslessTxUseCase = estimateFeeForGaslessTxUseCase,
getFeeForTokenUseCase = getFeeForTokenUseCase,
createAndSendGaslessTransactionUseCase = createAndSendGaslessTransactionUseCase,
getFeeUseCase = getFeeUseCase,
getEthSpecificFeeUseCase = getEthSpecificFeeUseCase,
getCurrencyCheckUseCase = getCurrencyCheckUseCase,
getAssetRequirementsUseCase = getAssetRequirementsUseCase,
amountFormatter = amountFormatter,
@ -127,6 +113,8 @@ internal open class SwapInteractorImplTestBase {
walletManagersFacade = walletManagersFacade,
getAllowanceInfoUseCase = getAllowanceInfoUseCase,
getSwapPairUseCase = getSwapPairUseCase,
dexSwapFeeCalculator = dexSwapFeeCalculator,
cexSwapFeeCalculator = cexSwapFeeCalculator,
)
}
@ -146,19 +134,6 @@ internal open class SwapInteractorImplTestBase {
clearAllMocks()
unmockkAll()
}
/**
* Defensive shutdown hook releases any remaining `mockkStatic` / `mockkObject` declarations
* after the entire test class finishes, in case `@AfterEach` was bypassed (e.g. JVM shutdown
* during a hard crash).
*
* Requires `@TestInstance(Lifecycle.PER_CLASS)` on every subclass already the case across
* all `SwapInteractorImpl*Test` classes.
*/
@AfterAll
open fun releaseStaticMocksAfterAllTests() {
unmockkAll()
}
}
// region — Test Builders
@ -298,37 +273,32 @@ internal fun buildSwapProvider(
)
/**
* Builds a [TxFee.FeeComponent] wrapping a [Fee.Common] with the given fiat-equivalent amount.
* Builds a [SwapFee] wrapping a [Fee.Common] with the given fiat-equivalent amount.
*/
internal fun buildTxFee(
internal fun buildSwapFee(
feeValue: BigDecimal = BigDecimal("0.001"),
selectedToken: CryptoCurrencyStatus? = null,
): TxFee.FeeComponent {
selectedFeeToken: CryptoCurrencyStatus = buildSwapCurrencyStatus().status,
otherNativeFee: BigDecimal = BigDecimal.ZERO,
feeBucket: com.tangem.feature.swap.domain.models.ui.FeeBucket =
com.tangem.feature.swap.domain.models.ui.FeeBucket.MARKET,
): SwapFee {
val amount = mockk<Amount>(relaxed = true) {
every { value } returns feeValue
}
val fee = mockk<Fee.Common>(relaxed = true) {
every { this@mockk.amount } returns amount
}
return TxFee.FeeComponent(
return SwapFee(
fee = fee,
transactionFeeResult = TransactionFeeResult.Loaded(
fee = mockk<TransactionFee.Single>(relaxed = true),
),
selectedToken = selectedToken,
selectedFeeToken = selectedFeeToken,
otherNativeFee = otherNativeFee,
feeBucket = feeBucket,
)
}
/**
* Builds a [TxFeeSealedState.Component] wrapping a [TxFee.FeeComponent].
*/
internal fun buildTxFeeSealedState(
feeValue: BigDecimal = BigDecimal("0.001"),
selectedToken: CryptoCurrencyStatus? = null,
): TxFeeSealedState = TxFeeSealedState.Component(
txFee = buildTxFee(feeValue = feeValue, selectedToken = selectedToken),
)
/**
* Builds a [SwapPairLeast] with matching from/to network+contract pairs.
*/

View file

@ -0,0 +1,368 @@
package com.tangem.feature.swap.domain.fee
import arrow.core.left
import arrow.core.right
import com.google.common.truth.Truth.assertThat
import com.tangem.blockchain.common.Amount
import com.tangem.blockchain.common.Blockchain
import com.tangem.blockchain.common.transaction.Fee
import com.tangem.blockchain.common.transaction.TransactionFee
import com.tangem.blockchainsdk.utils.toNetworkId
import com.tangem.domain.models.currency.CryptoCurrency
import com.tangem.domain.models.currency.CryptoCurrencyStatus
import com.tangem.domain.models.wallet.UserWallet
import com.tangem.domain.transaction.error.GetFeeError
import com.tangem.domain.transaction.models.TransactionFeeExtended
import com.tangem.domain.transaction.usecase.EstimateFeeUseCase
import com.tangem.domain.transaction.usecase.gasless.EstimateFeeForGaslessTxUseCase
import com.tangem.domain.transaction.usecase.gasless.EstimateFeeForTokenUseCase
import com.tangem.feature.swap.domain.buildSwapCurrencyStatus
import io.mockk.*
import kotlinx.coroutines.test.runTest
import org.junit.jupiter.api.AfterEach
import org.junit.jupiter.api.Test
import org.junit.jupiter.api.TestInstance
import java.math.BigDecimal
import java.math.BigInteger
/**
* Unit tests for [CexSwapFeeCalculator].
*
* Mirrors the CEX paths in `SwapInteractorImpl.loadFeeForSwapTransaction` (overload 2 native +
* overload 1 token/gasless) and `getFeeForCex`, but exercises the new helper directly.
*/
@TestInstance(TestInstance.Lifecycle.PER_CLASS)
internal class CexSwapFeeCalculatorTest {
private val ethNetwork = Blockchain.Ethereum.toNetworkId()
private val estimateFeeUseCase: EstimateFeeUseCase = mockk(relaxed = true)
private val estimateFeeForTokenUseCase: EstimateFeeForTokenUseCase = mockk(relaxed = true)
private val estimateFeeForGaslessTxUseCase: EstimateFeeForGaslessTxUseCase = mockk(relaxed = true)
private val sendBump = PatchEthGasLimitForSwap(percentage = PatchEthGasLimitForSwap.SEND_PERCENTAGE)
private val sut: CexSwapFeeCalculator by lazy {
CexSwapFeeCalculator(
estimateFeeUseCase = estimateFeeUseCase,
estimateFeeForTokenUseCase = estimateFeeForTokenUseCase,
estimateFeeForGaslessTxUseCase = estimateFeeForGaslessTxUseCase,
patchEthGasLimitForSwap = sendBump,
)
}
@AfterEach
fun tearDown() {
clearAllMocks()
}
// -------------------------------------------------------------------------
// Zero amount short-circuit
// -------------------------------------------------------------------------
@Test
fun `GIVEN zero amount WHEN calculate THEN returns Left UnknownError`() = runTest {
val fromStatus = buildSwapCurrencyStatus(networkRawId = ethNetwork)
val result = sut.calculate(
userWallet = fromStatus.userWallet,
fromSwapCurrencyStatus = fromStatus,
amount = BigDecimal.ZERO,
selectedFeeToken = null,
)
assertThat(result.isLeft()).isTrue()
result.onLeft { assertThat(it).isInstanceOf(GetFeeError.UnknownError::class.java) }
// None of the fee use cases were invoked
coVerify(exactly = 0) {
estimateFeeUseCase.invoke(any(), any(), any())
estimateFeeForTokenUseCase.invoke(any(), any(), any(), any())
estimateFeeForGaslessTxUseCase.invoke(any(), any(), any())
}
}
// -------------------------------------------------------------------------
// Gasless path (selectedFeeToken == null)
// -------------------------------------------------------------------------
@Test
fun `GIVEN null selectedFeeToken WHEN calculate THEN delegates to estimateFeeForGaslessTxUseCase`() = runTest {
val fromStatus = buildSwapCurrencyStatus(networkRawId = ethNetwork)
val expected = mockk<TransactionFeeExtended>(relaxed = true)
coEvery {
estimateFeeForGaslessTxUseCase(any(), any(), any())
} returns expected.right()
val result = sut.calculate(
userWallet = fromStatus.userWallet,
fromSwapCurrencyStatus = fromStatus,
amount = BigDecimal("1.5"),
selectedFeeToken = null,
)
assertThat(result.isRight()).isTrue()
result.onRight { cexResult ->
val loaded = cexResult.transactionFee as TransactionFeeResult.LoadedExtended
assertThat(loaded.fee).isSameInstanceAs(expected)
}
coVerify(exactly = 1) {
estimateFeeForGaslessTxUseCase.invoke(
amount = BigDecimal("1.5"),
userWallet = fromStatus.userWallet,
sendingTokenCurrencyStatus = fromStatus.status,
)
}
// Other use cases are NOT called.
coVerify(exactly = 0) {
estimateFeeUseCase.invoke(any(), any(), any())
estimateFeeForTokenUseCase.invoke(any(), any(), any(), any())
}
}
@Test
fun `GIVEN gasless path returns Left WHEN calculate THEN error is propagated`() = runTest {
val fromStatus = buildSwapCurrencyStatus(networkRawId = ethNetwork)
coEvery {
estimateFeeForGaslessTxUseCase(any(), any(), any())
} returns GetFeeError.GaslessError.NoSupportedTokensFound.left()
val result = sut.calculate(
userWallet = fromStatus.userWallet,
fromSwapCurrencyStatus = fromStatus,
amount = BigDecimal("1.0"),
selectedFeeToken = null,
)
assertThat(result.isLeft()).isTrue()
result.onLeft { error ->
assertThat(error).isInstanceOf(GetFeeError.GaslessError.NoSupportedTokensFound::class.java)
}
}
// -------------------------------------------------------------------------
// Explicit token path (selectedFeeToken is Token)
// -------------------------------------------------------------------------
@Test
fun `GIVEN explicit token selectedFeeToken WHEN calculate THEN delegates to estimateFeeForTokenUseCase`() =
runTest {
val fromStatus = buildSwapCurrencyStatus(networkRawId = ethNetwork)
val tokenCurrency = mockk<CryptoCurrency.Token>(relaxed = true)
val tokenStatus = mockk<CryptoCurrencyStatus>(relaxed = true) {
every { currency } returns tokenCurrency
}
val expected = mockk<TransactionFeeExtended>(relaxed = true)
coEvery {
estimateFeeForTokenUseCase(any(), any(), any(), any())
} returns expected.right()
val result = sut.calculate(
userWallet = fromStatus.userWallet,
fromSwapCurrencyStatus = fromStatus,
amount = BigDecimal("2.0"),
selectedFeeToken = tokenStatus,
)
assertThat(result.isRight()).isTrue()
result.onRight { cexResult ->
val loaded = cexResult.transactionFee as TransactionFeeResult.LoadedExtended
assertThat(loaded.fee).isSameInstanceAs(expected)
}
coVerify(exactly = 1) {
estimateFeeForTokenUseCase.invoke(
userWallet = fromStatus.userWallet,
feeTokenCurrencyStatus = tokenStatus,
sendingTokenCurrencyStatus = fromStatus.status,
amount = BigDecimal("2.0"),
)
}
coVerify(exactly = 0) {
estimateFeeUseCase.invoke(any(), any(), any())
estimateFeeForGaslessTxUseCase.invoke(any(), any(), any())
}
}
// -------------------------------------------------------------------------
// Explicit native path (selectedFeeToken is Coin) — applies 5% bump
// -------------------------------------------------------------------------
@Test
fun `GIVEN explicit native selectedFeeToken WHEN calculate THEN delegates to estimateFeeUseCase and applies 5 percent bump on Ethereum`() =
runTest {
val fromStatus = buildSwapCurrencyStatus(networkRawId = ethNetwork)
val coinCurrency = mockk<CryptoCurrency.Coin>(relaxed = true)
val coinStatus = mockk<CryptoCurrencyStatus>(relaxed = true) {
every { currency } returns coinCurrency
}
val rawFee = Fee.Ethereum.Legacy(
amount = Amount(currencySymbol = "ETH", value = BigDecimal("0.000002"), decimals = 18),
gasLimit = BigInteger.valueOf(100_000),
gasPrice = BigInteger.valueOf(20_000_000_000),
)
coEvery {
estimateFeeUseCase(any(), any(), any())
} returns TransactionFee.Single(normal = rawFee).right()
val result = sut.calculate(
userWallet = fromStatus.userWallet,
fromSwapCurrencyStatus = fromStatus,
amount = BigDecimal("3.0"),
selectedFeeToken = coinStatus,
)
assertThat(result.isRight()).isTrue()
result.onRight { cexResult ->
val loaded = cexResult.transactionFee as TransactionFeeResult.Loaded
val patched = (loaded.fee as TransactionFee.Single).normal as Fee.Ethereum.Legacy
// 100_000 * 105 / 100 = 105_000
assertThat(patched.gasLimit).isEqualTo(BigInteger.valueOf(105_000))
// 105_000 * 20_000_000_000 / 1e18 = 0.0000021
assertThat(patched.amount.value).isEquivalentAccordingToCompareTo(BigDecimal("0.0000021"))
}
coVerify(exactly = 1) {
estimateFeeUseCase.invoke(
amount = BigDecimal("3.0"),
userWallet = fromStatus.userWallet,
cryptoCurrencyStatus = fromStatus.status,
)
}
coVerify(exactly = 0) {
estimateFeeForTokenUseCase.invoke(any(), any(), any(), any())
estimateFeeForGaslessTxUseCase.invoke(any(), any(), any())
}
}
@Test
fun `GIVEN explicit native selectedFeeToken with non-Ethereum fee WHEN calculate THEN bump is a no-op`() =
runTest {
val fromStatus = buildSwapCurrencyStatus(networkRawId = ethNetwork)
val coinCurrency = mockk<CryptoCurrency.Coin>(relaxed = true)
val coinStatus = mockk<CryptoCurrencyStatus>(relaxed = true) {
every { currency } returns coinCurrency
}
val rawFee = Fee.Common(
amount = Amount(currencySymbol = "BTC", value = BigDecimal("0.0001"), decimals = 8),
)
coEvery {
estimateFeeUseCase(any(), any(), any())
} returns TransactionFee.Single(normal = rawFee).right()
val result = sut.calculate(
userWallet = fromStatus.userWallet,
fromSwapCurrencyStatus = fromStatus,
amount = BigDecimal("1.0"),
selectedFeeToken = coinStatus,
)
result.onRight { cexResult ->
val loaded = cexResult.transactionFee as TransactionFeeResult.Loaded
val unchanged = (loaded.fee as TransactionFee.Single).normal as Fee.Common
assertThat(unchanged).isSameInstanceAs(rawFee)
}
}
@Test
fun `GIVEN native path returns Left WHEN calculate THEN error is propagated`() = runTest {
val fromStatus = buildSwapCurrencyStatus(networkRawId = ethNetwork)
val coinCurrency = mockk<CryptoCurrency.Coin>(relaxed = true)
val coinStatus = mockk<CryptoCurrencyStatus>(relaxed = true) {
every { currency } returns coinCurrency
}
coEvery {
estimateFeeUseCase(any(), any(), any())
} returns GetFeeError.UnknownError.left()
val result = sut.calculate(
userWallet = fromStatus.userWallet,
fromSwapCurrencyStatus = fromStatus,
amount = BigDecimal("1.0"),
selectedFeeToken = coinStatus,
)
assertThat(result.isLeft()).isTrue()
result.onLeft { assertThat(it).isInstanceOf(GetFeeError.UnknownError::class.java) }
}
// -------------------------------------------------------------------------
// Choosable Ethereum fee (multiple legs) — bump applied to every leg
// -------------------------------------------------------------------------
@Test
fun `GIVEN explicit native with Choosable Ethereum fee WHEN calculate THEN bump applied to all three legs`() =
runTest {
val fromStatus = buildSwapCurrencyStatus(networkRawId = ethNetwork)
val coinCurrency = mockk<CryptoCurrency.Coin>(relaxed = true)
val coinStatus = mockk<CryptoCurrencyStatus>(relaxed = true) {
every { currency } returns coinCurrency
}
val gasPrice = BigInteger.valueOf(10_000_000_000)
val rawFee = TransactionFee.Choosable(
minimum = Fee.Ethereum.Legacy(
amount = Amount(currencySymbol = "ETH", value = BigDecimal("0.000001"), decimals = 18),
gasLimit = BigInteger.valueOf(50_000),
gasPrice = gasPrice,
),
normal = Fee.Ethereum.Legacy(
amount = Amount(currencySymbol = "ETH", value = BigDecimal("0.000002"), decimals = 18),
gasLimit = BigInteger.valueOf(100_000),
gasPrice = gasPrice,
),
priority = Fee.Ethereum.Legacy(
amount = Amount(currencySymbol = "ETH", value = BigDecimal("0.000003"), decimals = 18),
gasLimit = BigInteger.valueOf(150_000),
gasPrice = gasPrice,
),
)
coEvery {
estimateFeeUseCase(any(), any(), any())
} returns rawFee.right()
val result = sut.calculate(
userWallet = fromStatus.userWallet,
fromSwapCurrencyStatus = fromStatus,
amount = BigDecimal("1.0"),
selectedFeeToken = coinStatus,
)
result.onRight { cexResult ->
val loaded = cexResult.transactionFee as TransactionFeeResult.Loaded
val patched = loaded.fee as TransactionFee.Choosable
assertThat((patched.minimum as Fee.Ethereum.Legacy).gasLimit)
.isEqualTo(BigInteger.valueOf(52_500))
assertThat((patched.normal as Fee.Ethereum.Legacy).gasLimit)
.isEqualTo(BigInteger.valueOf(105_000))
assertThat((patched.priority as Fee.Ethereum.Legacy).gasLimit)
.isEqualTo(BigInteger.valueOf(157_500))
}
}
// -------------------------------------------------------------------------
// userWallet propagation
// -------------------------------------------------------------------------
@Test
fun `GIVEN gasless path WHEN calculate THEN userWallet is propagated to estimateFeeForGaslessTxUseCase`() =
runTest {
val fromStatus = buildSwapCurrencyStatus(networkRawId = ethNetwork)
val customWallet = mockk<UserWallet>(relaxed = true)
val expected = mockk<TransactionFeeExtended>(relaxed = true)
coEvery {
estimateFeeForGaslessTxUseCase(any(), any(), any())
} returns expected.right()
sut.calculate(
userWallet = customWallet,
fromSwapCurrencyStatus = fromStatus,
amount = BigDecimal("1.0"),
selectedFeeToken = null,
)
coVerify(exactly = 1) {
estimateFeeForGaslessTxUseCase.invoke(
amount = BigDecimal("1.0"),
userWallet = customWallet,
sendingTokenCurrencyStatus = fromStatus.status,
)
}
}
}

View file

@ -0,0 +1,442 @@
package com.tangem.feature.swap.domain.fee
import android.util.Base64
import arrow.core.left
import arrow.core.right
import com.google.common.truth.Truth.assertThat
import com.tangem.blockchain.blockchains.solana.SolanaTransactionHelper
import com.tangem.blockchain.common.Amount
import com.tangem.blockchain.common.Blockchain
import com.tangem.blockchain.common.TransactionData
import com.tangem.blockchain.common.TransactionExtras
import com.tangem.blockchain.common.transaction.Fee
import com.tangem.blockchain.common.transaction.TransactionFee
import com.tangem.blockchainsdk.utils.toNetworkId
import com.tangem.domain.models.wallet.UserWallet
import com.tangem.domain.swap.models.SwapCurrencyStatus
import com.tangem.domain.transaction.error.GetFeeError
import com.tangem.domain.transaction.usecase.CreateTransactionDataExtrasUseCase
import com.tangem.domain.transaction.usecase.GetEthSpecificFeeUseCase
import com.tangem.domain.transaction.usecase.GetFeeUseCase
import com.tangem.domain.transaction.usecase.gasless.GetFeeForTokenUseCase
import com.tangem.domain.walletmanager.WalletManagersFacade
import com.tangem.feature.swap.domain.buildSwapCurrencyStatus
import com.tangem.feature.swap.domain.models.ExpressDataError
import com.tangem.feature.swap.domain.models.SwapAmount
import com.tangem.feature.swap.domain.models.domain.ExpressTransactionModel
import io.mockk.clearAllMocks
import io.mockk.coEvery
import io.mockk.coVerify
import io.mockk.every
import io.mockk.mockk
import io.mockk.mockkObject
import io.mockk.mockkStatic
import io.mockk.slot
import io.mockk.unmockkAll
import kotlinx.coroutines.test.runTest
import org.junit.jupiter.api.AfterEach
import org.junit.jupiter.api.BeforeEach
import org.junit.jupiter.api.Test
import org.junit.jupiter.api.TestInstance
import java.math.BigDecimal
import java.math.BigInteger
/**
* Unit tests for [DexSwapFeeCalculator] ([REDACTED_TASK_KEY] Phase 2).
*
* Mirrors the cases from `SwapInteractorImplLoadFeeForDexTest` and
* `SwapInteractorImplOtherNativeFeeTest` but exercises the calculator directly with a
* minimal set of mocks instead of going through the public `findBestQuote` entry point.
*/
@TestInstance(TestInstance.Lifecycle.PER_CLASS)
internal class DexSwapFeeCalculatorTest {
private val ethNetwork = Blockchain.Ethereum.toNetworkId()
private val solanaNetwork = Blockchain.Solana.toNetworkId()
private val getFeeUseCase: GetFeeUseCase = mockk(relaxed = true)
private val getEthSpecificFeeUseCase: GetEthSpecificFeeUseCase = mockk(relaxed = true)
private val getFeeForTokenUseCase: GetFeeForTokenUseCase = mockk(relaxed = true)
private val createTransactionExtrasUseCase: CreateTransactionDataExtrasUseCase = mockk(relaxed = true)
private val walletManagersFacade: WalletManagersFacade = mockk(relaxed = true)
private val dexBump = PatchEthGasLimitForSwap(percentage = PatchEthGasLimitForSwap.DEX_PERCENTAGE)
private val sut: DexSwapFeeCalculator by lazy {
DexSwapFeeCalculator(
getFeeUseCase = getFeeUseCase,
getEthSpecificFeeUseCase = getEthSpecificFeeUseCase,
getFeeForTokenUseCase = getFeeForTokenUseCase,
createTransactionExtrasUseCase = createTransactionExtrasUseCase,
walletManagersFacade = walletManagersFacade,
patchEthGasLimitForSwap = dexBump,
)
}
@BeforeEach
fun setup() {
// Default: native balance is plenty.
coEvery { walletManagersFacade.getNativeTokenBalance(any(), any(), any()) } returns BigDecimal("10")
every { createTransactionExtrasUseCase.invoke(data = any(), network = any()) } returns
mockk<TransactionExtras>(relaxed = true).right()
}
@AfterEach
fun tearDown() {
clearAllMocks()
unmockkAll()
}
// -------------------------------------------------------------------------
// EVM happy path
// -------------------------------------------------------------------------
@Test
fun `EVM DEX swap propagates extras destinationAddress sourceAddress and amount to getFeeUseCase`() = runTest {
val fromStatus = buildSwapCurrencyStatus(networkRawId = ethNetwork, isCoin = true)
val transaction = buildDex(
txValue = "1000000000000000", // 0.001 ETH
txTo = "0xRecipient",
txFrom = "0xSender",
txData = "0xPayload",
)
val capturedTxData = slot<TransactionData>()
coEvery {
getFeeUseCase.invoke(
userWallet = any(),
network = any(),
transactionData = capture(capturedTxData),
)
} returns mockk<TransactionFee.Single>(relaxed = true).right()
sut.calculate(fromStatus, transaction)
assertThat(capturedTxData.isCaptured).isTrue()
val uncompiled = capturedTxData.captured as TransactionData.Uncompiled
assertThat(uncompiled.destinationAddress).isEqualTo("0xRecipient")
assertThat(uncompiled.sourceAddress).isEqualTo("0xSender")
// amount.value is the txValue moved-point-left by native decimals (18 for ETH) → 0.001
assertThat(uncompiled.amount.value).isEquivalentAccordingToCompareTo(BigDecimal("0.001"))
// extras came from createTransactionExtrasUseCase
assertThat(uncompiled.extras).isNotNull()
}
// -------------------------------------------------------------------------
// EVM zero-balance short-circuit
// -------------------------------------------------------------------------
@Test
fun `EVM DEX swap with native balance ZERO returns Left UnknownError`() = runTest {
val fromStatus = buildSwapCurrencyStatus(networkRawId = ethNetwork, isCoin = true)
val transaction = buildDex(txValue = "0")
coEvery { walletManagersFacade.getNativeTokenBalance(any(), any(), any()) } returns BigDecimal.ZERO
val result = sut.calculate(fromStatus, transaction)
assertThat(result.isLeft()).isTrue()
result.onLeft { assertThat(it).isEqualTo(ExpressDataError.UnknownError()) }
// getFeeUseCase should not have been called because balance check short-circuits first.
// Use a more permissive verify to avoid clashing with the other overload signatures.
coVerify(exactly = 0) {
getFeeUseCase.invoke(
userWallet = any(),
network = any(),
transactionData = any<TransactionData>(),
)
}
}
// -------------------------------------------------------------------------
// EVM IllegalStateException → fallback to GetEthSpecificFeeUseCase
// -------------------------------------------------------------------------
@Test
fun `EVM DEX swap falls back to getEthSpecificFeeUseCase when txValue is null`() = runTest {
val fromStatus = buildSwapCurrencyStatus(networkRawId = ethNetwork, isCoin = true)
val gas = BigInteger.valueOf(150_000L)
val transaction = buildDex(txValue = null, gas = gas)
coEvery {
getEthSpecificFeeUseCase.invoke(
userWallet = any(),
cryptoCurrency = any(),
gasLimit = any(),
gasPrice = any(),
)
} returns mockk<TransactionFee.Choosable>(relaxed = true).right()
sut.calculate(fromStatus, transaction)
coVerify(exactly = 1) {
getEthSpecificFeeUseCase.invoke(
userWallet = any(),
cryptoCurrency = any(),
gasLimit = gas,
gasPrice = any(),
)
}
}
@Test
fun `EVM DEX swap falls back to getEthSpecificFeeUseCase when createTransactionExtrasUseCase fails`() = runTest {
val fromStatus = buildSwapCurrencyStatus(networkRawId = ethNetwork, isCoin = true)
val gas = BigInteger.valueOf(75_000L)
val transaction = buildDex(txValue = "1000000000000000", gas = gas)
every {
createTransactionExtrasUseCase.invoke(data = any(), network = any())
} returns IllegalStateException("forced fail").left()
coEvery {
getEthSpecificFeeUseCase.invoke(
userWallet = any(),
cryptoCurrency = any(),
gasLimit = any(),
gasPrice = any(),
)
} returns mockk<TransactionFee.Choosable>(relaxed = true).right()
sut.calculate(fromStatus, transaction)
coVerify(exactly = 1) {
getEthSpecificFeeUseCase.invoke(
userWallet = any(),
cryptoCurrency = any(),
gasLimit = gas,
gasPrice = any(),
)
}
}
@Test
fun `EVM DEX swap falls back to getEthSpecificFeeUseCase when getFeeUseCase returns Left`() = runTest {
val fromStatus = buildSwapCurrencyStatus(networkRawId = ethNetwork, isCoin = true)
val gas = BigInteger.valueOf(50_000L)
val transaction = buildDex(txValue = "1000000000000000", gas = gas)
coEvery {
getFeeUseCase.invoke(userWallet = any(), network = any(), transactionData = any())
} returns GetFeeError.UnknownError.left()
coEvery {
getEthSpecificFeeUseCase.invoke(
userWallet = any(),
cryptoCurrency = any(),
gasLimit = any(),
gasPrice = any(),
)
} returns mockk<TransactionFee.Choosable>(relaxed = true).right()
sut.calculate(fromStatus, transaction)
coVerify(exactly = 1) {
getEthSpecificFeeUseCase.invoke(
userWallet = any(),
cryptoCurrency = any(),
gasLimit = gas,
gasPrice = any(),
)
}
}
// -------------------------------------------------------------------------
// 12% gas patch — golden numbers
// -------------------------------------------------------------------------
@Test
fun `EVM DEX swap applies 12 percent gas-limit bump on Ethereum Legacy fee`() = runTest {
val fromStatus = buildSwapCurrencyStatus(networkRawId = ethNetwork, isCoin = true)
val transaction = buildDex(txValue = "1000000000000000")
// amount = 100_000 * 20e9 / 1e18 = 0.000002 ETH (decimals = 18)
val rawFee = Fee.Ethereum.Legacy(
amount = Amount(currencySymbol = "ETH", value = BigDecimal("0.000002"), decimals = 18),
gasLimit = BigInteger.valueOf(100_000),
gasPrice = BigInteger.valueOf(20_000_000_000),
)
coEvery {
getFeeUseCase.invoke(userWallet = any(), network = any(), transactionData = any())
} returns TransactionFee.Single(normal = rawFee).right()
val result = sut.calculate(fromStatus, transaction)
assertThat(result.isRight()).isTrue()
result.onRight { dexFeeResult ->
val patched = (dexFeeResult.transactionFee as TransactionFeeResult.Loaded).fee
val patchedFee = (patched as TransactionFee.Single).normal as Fee.Ethereum.Legacy
// 100_000 * 112 / 100 = 112_000
assertThat(patchedFee.gasLimit).isEqualTo(BigInteger.valueOf(112_000))
// 112_000 * 20_000_000_000 / 1e18 = 0.00000224
assertThat(patchedFee.amount.value).isEquivalentAccordingToCompareTo(BigDecimal("0.00000224"))
// Gas is propagated for downstream consumers
assertThat(dexFeeResult.gas).isEqualTo(transaction.gas)
}
}
// -------------------------------------------------------------------------
// Solana DEX path
// -------------------------------------------------------------------------
@Test
fun `Solana DEX uses TransactionData Compiled and skips the 12 percent gas patch`() = runTest {
mockkStatic(Base64::class)
every { Base64.decode(any<String>(), any()) } returns ByteArray(64)
mockkObject(SolanaTransactionHelper)
every { SolanaTransactionHelper.removeSignaturesPlaceholders(any()) } returns ByteArray(64)
val fromStatus = buildSwapCurrencyStatus(networkRawId = solanaNetwork, isCoin = true)
val transaction = buildDex(txData = "U29sYW5h")
val rawFeeAmount = BigDecimal("0.005000")
val rawFee: Fee = Fee.Common(
amount = Amount(currencySymbol = "SOL", value = rawFeeAmount, decimals = 9),
)
val txFee = TransactionFee.Single(normal = rawFee)
val capturedTxData = slot<TransactionData>()
coEvery {
getFeeUseCase.invoke(
userWallet = any(),
network = any(),
transactionData = capture(capturedTxData),
)
} returns txFee.right()
val result = sut.calculate(fromStatus, transaction)
assertThat(capturedTxData.isCaptured).isTrue()
assertThat(capturedTxData.captured).isInstanceOf(TransactionData.Compiled::class.java)
result.onRight { dexFeeResult ->
// No bump: Fee.Common is non-Ethereum even on the EVM path; on Solana the bump isn't
// applied at all. The raw value is preserved.
val patched = (dexFeeResult.transactionFee as TransactionFeeResult.Loaded).fee
val solFee = (patched as TransactionFee.Single).normal as Fee.Common
assertThat(solFee.amount.value).isEquivalentAccordingToCompareTo(rawFeeAmount)
// Solana path leaves gas null (caller doesn't need it).
assertThat(dexFeeResult.gas).isNull()
}
}
@Test
fun `Solana DEX size guard returns Left TooLargeSolanaTransactionError on Cold wallet`() = runTest {
mockkStatic(Base64::class)
val oversizedBytes = ByteArray(1300)
every { Base64.decode(any<String>(), any()) } returns oversizedBytes
mockkObject(SolanaTransactionHelper)
every { SolanaTransactionHelper.removeSignaturesPlaceholders(any()) } returns oversizedBytes
val coldWallet = mockk<UserWallet.Cold>(relaxed = true)
val baseStatus = buildSwapCurrencyStatus(networkRawId = solanaNetwork, isCoin = true)
val fromStatus = SwapCurrencyStatus(
userWallet = coldWallet,
status = baseStatus.status,
account = baseStatus.account,
)
val transaction = buildDex(txData = "very-long-base64-content==")
val result = sut.calculate(fromStatus, transaction)
assertThat(result.isLeft()).isTrue()
result.onLeft { error ->
assertThat(error).isEqualTo(ExpressDataError.TooLargeSolanaTransactionError())
}
// No fee is computed when the size guard trips
coVerify(exactly = 0) {
getFeeUseCase.invoke(userWallet = any(), network = any(), transactionData = any())
}
}
// -------------------------------------------------------------------------
// otherNativeFee propagation (bridge protocol fee)
// -------------------------------------------------------------------------
@Test
fun `EVM DEX swap propagates otherNativeFee with native decimals when otherNativeFeeWei is set`() = runTest {
val fromStatus = buildSwapCurrencyStatus(networkRawId = ethNetwork, isCoin = true)
// 0.5 ETH expressed in wei (1e18)
val transaction = buildDex(
txValue = "1000000000000000",
otherNativeFeeWei = BigDecimal("500000000000000000"),
)
coEvery {
getFeeUseCase.invoke(userWallet = any(), network = any(), transactionData = any())
} returns TransactionFee.Single(normal = ethLegacyFee()).right()
val result = sut.calculate(fromStatus, transaction)
result.onRight { dexFeeResult ->
assertThat(dexFeeResult.otherNativeFee).isEquivalentAccordingToCompareTo(BigDecimal("0.5"))
}
}
@Test
fun `EVM DEX swap returns ZERO otherNativeFee when otherNativeFeeWei is null`() = runTest {
val fromStatus = buildSwapCurrencyStatus(networkRawId = ethNetwork, isCoin = true)
val transaction = buildDex(txValue = "1000000000000000", otherNativeFeeWei = null)
coEvery {
getFeeUseCase.invoke(userWallet = any(), network = any(), transactionData = any())
} returns TransactionFee.Single(normal = ethLegacyFee()).right()
val result = sut.calculate(fromStatus, transaction)
result.onRight { dexFeeResult ->
assertThat(dexFeeResult.otherNativeFee).isEqualTo(BigDecimal.ZERO)
}
}
@Test
fun `Solana DEX swap propagates otherNativeFee using native decimals`() = runTest {
mockkStatic(Base64::class)
every { Base64.decode(any<String>(), any()) } returns ByteArray(64)
mockkObject(SolanaTransactionHelper)
every { SolanaTransactionHelper.removeSignaturesPlaceholders(any()) } returns ByteArray(64)
val fromStatus = buildSwapCurrencyStatus(networkRawId = solanaNetwork, isCoin = true, decimals = 9)
// 1.5 SOL expressed with 9 decimals = 1_500_000_000
val transaction = buildDex(
txData = "U29sYW5h",
otherNativeFeeWei = BigDecimal("1500000000"),
)
coEvery {
getFeeUseCase.invoke(userWallet = any(), network = any(), transactionData = any())
} returns TransactionFee.Single(
normal = Fee.Common(Amount(currencySymbol = "SOL", value = BigDecimal("0.005"), decimals = 9)),
).right()
val result = sut.calculate(fromStatus, transaction)
result.onRight { dexFeeResult ->
assertThat(dexFeeResult.otherNativeFee).isEquivalentAccordingToCompareTo(BigDecimal("1.5"))
}
}
// -------------------------------------------------------------------------
// Helpers
// -------------------------------------------------------------------------
private fun ethLegacyFee(): Fee.Ethereum.Legacy = Fee.Ethereum.Legacy(
amount = Amount(currencySymbol = "ETH", value = BigDecimal("0.000002"), decimals = 18),
gasLimit = BigInteger.valueOf(100_000),
gasPrice = BigInteger.valueOf(20_000_000_000),
)
private fun buildDex(
txData: String = "dGVzdA==",
txValue: String? = "0",
toAmount: BigDecimal = BigDecimal("0.5"),
otherNativeFeeWei: BigDecimal? = null,
gas: BigInteger = BigInteger.valueOf(21_000L),
txTo: String = "0xRecipient",
txFrom: String = "0xSender",
): ExpressTransactionModel.DEX = ExpressTransactionModel.DEX(
fromAmount = SwapAmount(BigDecimal.ONE, 18),
toAmount = SwapAmount(toAmount, 18),
txValue = txValue,
txId = "tx-id-123",
txTo = txTo,
txExtraId = null,
txFrom = txFrom,
txData = txData,
otherNativeFeeWei = otherNativeFeeWei,
gas = gas,
)
}

View file

@ -0,0 +1,298 @@
package com.tangem.feature.swap.domain.fee
import com.google.common.truth.Truth.assertThat
import com.tangem.blockchain.common.Amount
import com.tangem.blockchain.common.transaction.Fee
import com.tangem.blockchain.common.transaction.TransactionFee
import org.junit.jupiter.api.Test
import org.junit.jupiter.api.TestInstance
import org.junit.jupiter.api.assertThrows
import java.math.BigDecimal
import java.math.BigInteger
/**
* Pure-JVM unit tests for [com.tangem.feature.swap.domain.fee.PatchEthGasLimitForSwap] ([REDACTED_TASK_KEY]).
*
* Pinned behavior these tests guard the gas-bump arithmetic against accidental drift in:
* - Ethereum [com.tangem.blockchain.common.transaction.Fee.Ethereum.Legacy] / [com.tangem.blockchain.common.transaction.Fee.Ethereum.EIP1559]: gasLimit *= percentage / 100,
* amount = (newGasLimit * gasPrice) shifted left by amount decimals, decimals preserved.
* - [com.tangem.blockchain.common.transaction.Fee.Ethereum.TokenCurrency]: throws (current `error("handle in [REDACTED_TASK_KEY]")`).
* - All non-Ethereum [com.tangem.blockchain.common.transaction.Fee] subtypes: returned unchanged.
* - [com.tangem.blockchain.common.transaction.TransactionFee.Choosable]: applies the bump to all three legs (minimum/normal/priority).
* - [com.tangem.blockchain.common.transaction.TransactionFee.Single]: applies the bump to `normal`.
*/
@TestInstance(TestInstance.Lifecycle.PER_CLASS)
internal class PatchEthGasLimitForSwapTest {
private val dexBump = PatchEthGasLimitForSwap(percentage = PatchEthGasLimitForSwap.DEX_PERCENTAGE)
private val sendBump = PatchEthGasLimitForSwap(percentage = PatchEthGasLimitForSwap.SEND_PERCENTAGE)
// region Ethereum.Legacy
@Test
fun `GIVEN Ethereum Legacy fee WHEN dex bump applied THEN gasLimit is multiplied by 112 percent`() {
// amount = gasLimit * gasPrice shifted left by 18 → 100000 * 20_000_000_000 / 1e18 = 0.000002 ETH
val gasLimit = BigInteger.valueOf(100_000)
val gasPrice = BigInteger.valueOf(20_000_000_000) // 20 gwei
val amountValue = BigDecimal("0.000002") // 100_000 * 20e9 / 1e18
val initialFee = Fee.Ethereum.Legacy(
amount = ethAmount(amountValue, decimals = 18),
gasLimit = gasLimit,
gasPrice = gasPrice,
)
val result = dexBump(TransactionFee.Single(normal = initialFee))
val patched = (result as TransactionFee.Single).normal as Fee.Ethereum.Legacy
// 100_000 * 112 / 100 = 112_000
assertThat(patched.gasLimit).isEqualTo(BigInteger.valueOf(112_000))
// amount = 112_000 * 20_000_000_000 / 1e18 = 0.00000224
assertThat(patched.amount.value).isEquivalentAccordingToCompareTo(BigDecimal("0.00000224"))
// amount decimals must be preserved
assertThat(patched.amount.decimals).isEqualTo(18)
// gasPrice unchanged
assertThat(patched.gasPrice).isEqualTo(gasPrice)
}
@Test
fun `GIVEN Ethereum Legacy fee WHEN send bump applied THEN gasLimit is multiplied by 105 percent`() {
val gasLimit = BigInteger.valueOf(100_000)
val gasPrice = BigInteger.valueOf(20_000_000_000)
val initialFee = Fee.Ethereum.Legacy(
amount = ethAmount(BigDecimal("0.000002"), decimals = 18),
gasLimit = gasLimit,
gasPrice = gasPrice,
)
val result = sendBump(TransactionFee.Single(normal = initialFee))
val patched = (result as TransactionFee.Single).normal as Fee.Ethereum.Legacy
// 100_000 * 105 / 100 = 105_000
assertThat(patched.gasLimit).isEqualTo(BigInteger.valueOf(105_000))
// amount = 105_000 * 20_000_000_000 / 1e18 = 0.0000021
assertThat(patched.amount.value).isEquivalentAccordingToCompareTo(BigDecimal("0.0000021"))
assertThat(patched.amount.decimals).isEqualTo(18)
assertThat(patched.gasPrice).isEqualTo(gasPrice)
}
// endregion
// region Ethereum.EIP1559
@Test
fun `GIVEN Ethereum EIP1559 fee WHEN dex bump applied THEN gasLimit and amount are bumped`() {
val gasLimit = BigInteger.valueOf(50_000)
// Pretend gasPrice (effective) is 30 gwei → amount = 50_000 * 30e9 / 1e18 = 0.0000015
val initialFee = Fee.Ethereum.EIP1559(
amount = ethAmount(BigDecimal("0.0000015"), decimals = 18),
gasLimit = gasLimit,
maxFeePerGas = BigInteger.valueOf(40_000_000_000),
priorityFee = BigInteger.valueOf(2_000_000_000),
)
val result = dexBump(TransactionFee.Single(normal = initialFee))
val patched = (result as TransactionFee.Single).normal as Fee.Ethereum.EIP1559
// 50_000 * 112 / 100 = 56_000
assertThat(patched.gasLimit).isEqualTo(BigInteger.valueOf(56_000))
// amount = 56_000 * 30e9 / 1e18 = 0.00000168
assertThat(patched.amount.value).isEquivalentAccordingToCompareTo(BigDecimal("0.00000168"))
assertThat(patched.amount.decimals).isEqualTo(18)
// EIP1559-specific fields unchanged
assertThat(patched.maxFeePerGas).isEqualTo(BigInteger.valueOf(40_000_000_000))
assertThat(patched.priorityFee).isEqualTo(BigInteger.valueOf(2_000_000_000))
}
// endregion
// region Ethereum.TokenCurrency throws
@Test
fun `GIVEN Ethereum TokenCurrency fee WHEN dex bump applied THEN throws IllegalStateException`() {
val tokenFee = Fee.Ethereum.TokenCurrency(
amount = ethAmount(BigDecimal("0.001"), decimals = 18),
gasLimit = BigInteger.valueOf(100_000),
coinPriceInToken = BigInteger.ONE,
feeTransferGasLimit = BigInteger.valueOf(50_000),
baseGas = BigInteger.valueOf(21_000),
)
assertThrows<IllegalStateException> {
dexBump(TransactionFee.Single(normal = tokenFee))
}
}
@Test
fun `GIVEN Ethereum TokenCurrency fee WHEN dex bump applied THEN error message points to [REDACTED_TASK_KEY]`() {
val tokenFee = Fee.Ethereum.TokenCurrency(
amount = ethAmount(BigDecimal("0.001"), decimals = 18),
gasLimit = BigInteger.valueOf(100_000),
coinPriceInToken = BigInteger.ONE,
feeTransferGasLimit = BigInteger.valueOf(50_000),
baseGas = BigInteger.valueOf(21_000),
)
val thrown = runCatching { dexBump(TransactionFee.Single(normal = tokenFee)) }.exceptionOrNull()
assertThat(thrown).isInstanceOf(IllegalStateException::class.java)
assertThat(thrown?.message).contains("[REDACTED_TASK_KEY]")
}
// endregion
// region Non-Ethereum subtypes returned unchanged
@Test
fun `GIVEN Common fee WHEN dex bump applied THEN fee is unchanged`() {
val initial = Fee.Common(amount = ethAmount(BigDecimal("0.001"), decimals = 8))
val result = dexBump(TransactionFee.Single(normal = initial))
assertThat((result as TransactionFee.Single).normal).isSameInstanceAs(initial)
}
@Test
fun `GIVEN Bitcoin fee WHEN dex bump applied THEN fee is unchanged`() {
val initial = Fee.Bitcoin(
amount = ethAmount(BigDecimal("0.0001"), decimals = 8),
satoshiPerByte = BigDecimal("10"),
txSize = BigDecimal("250"),
)
val result = dexBump(TransactionFee.Single(normal = initial))
assertThat((result as TransactionFee.Single).normal).isSameInstanceAs(initial)
}
@Test
fun `GIVEN Tron fee WHEN dex bump applied THEN fee is unchanged`() {
val initial = Fee.Tron(
amount = ethAmount(BigDecimal("0.5"), decimals = 6),
remainingEnergy = 1000L,
feeEnergy = 100L,
)
val result = dexBump(TransactionFee.Single(normal = initial))
assertThat((result as TransactionFee.Single).normal).isSameInstanceAs(initial)
}
@Test
fun `GIVEN Sui fee WHEN dex bump applied THEN fee is unchanged`() {
val initial = Fee.Sui(
amount = ethAmount(BigDecimal("0.0001"), decimals = 9),
gasBudget = 10_000L,
gasPrice = 1_000L,
)
val result = dexBump(TransactionFee.Single(normal = initial))
assertThat((result as TransactionFee.Single).normal).isSameInstanceAs(initial)
}
@Test
fun `GIVEN Aptos fee WHEN dex bump applied THEN fee is unchanged`() {
val initial = Fee.Aptos(
amount = ethAmount(BigDecimal("0.0001"), decimals = 8),
gasUnitPrice = 100L,
gasLimit = 10_000L,
)
val result = dexBump(TransactionFee.Single(normal = initial))
assertThat((result as TransactionFee.Single).normal).isSameInstanceAs(initial)
}
@Test
fun `GIVEN Hedera fee WHEN dex bump applied THEN fee is unchanged`() {
val initial = Fee.Hedera(
amount = ethAmount(BigDecimal("0.001"), decimals = 8),
additionalHBARFee = BigDecimal.ZERO,
)
val result = dexBump(TransactionFee.Single(normal = initial))
assertThat((result as TransactionFee.Single).normal).isSameInstanceAs(initial)
}
// endregion
// region TransactionFee.Choosable bumps all three legs
@Test
fun `GIVEN Choosable fee with three Ethereum Legacy legs WHEN dex bump applied THEN every leg is bumped`() {
val legacyMin = Fee.Ethereum.Legacy(
amount = ethAmount(BigDecimal("0.000001"), decimals = 18),
gasLimit = BigInteger.valueOf(50_000),
gasPrice = BigInteger.valueOf(20_000_000_000),
)
val legacyNormal = Fee.Ethereum.Legacy(
amount = ethAmount(BigDecimal("0.000002"), decimals = 18),
gasLimit = BigInteger.valueOf(100_000),
gasPrice = BigInteger.valueOf(20_000_000_000),
)
val legacyPriority = Fee.Ethereum.Legacy(
amount = ethAmount(BigDecimal("0.000003"), decimals = 18),
gasLimit = BigInteger.valueOf(150_000),
gasPrice = BigInteger.valueOf(20_000_000_000),
)
val result = dexBump(
TransactionFee.Choosable(
minimum = legacyMin,
normal = legacyNormal,
priority = legacyPriority,
),
) as TransactionFee.Choosable
assertThat((result.minimum as Fee.Ethereum.Legacy).gasLimit).isEqualTo(BigInteger.valueOf(56_000))
assertThat((result.normal as Fee.Ethereum.Legacy).gasLimit).isEqualTo(BigInteger.valueOf(112_000))
assertThat((result.priority as Fee.Ethereum.Legacy).gasLimit).isEqualTo(BigInteger.valueOf(168_000))
}
@Test
fun `GIVEN Choosable fee with mixed legs WHEN bump applied THEN only Ethereum legs are bumped`() {
// Two Ethereum legs and one Common leg → only the Ethereum ones are scaled.
val ethMin = Fee.Ethereum.Legacy(
amount = ethAmount(BigDecimal("0.000001"), decimals = 18),
gasLimit = BigInteger.valueOf(50_000),
gasPrice = BigInteger.valueOf(10_000_000_000),
)
val commonNormal = Fee.Common(amount = ethAmount(BigDecimal("0.5"), decimals = 8))
val ethPriority = Fee.Ethereum.Legacy(
amount = ethAmount(BigDecimal("0.000003"), decimals = 18),
gasLimit = BigInteger.valueOf(150_000),
gasPrice = BigInteger.valueOf(10_000_000_000),
)
val result = sendBump(
TransactionFee.Choosable(
minimum = ethMin,
normal = commonNormal,
priority = ethPriority,
),
) as TransactionFee.Choosable
assertThat((result.minimum as Fee.Ethereum.Legacy).gasLimit).isEqualTo(BigInteger.valueOf(52_500))
assertThat(result.normal).isSameInstanceAs(commonNormal)
assertThat((result.priority as Fee.Ethereum.Legacy).gasLimit).isEqualTo(BigInteger.valueOf(157_500))
}
// endregion
// region Decimals preserved for non-18 decimals
@Test
fun `GIVEN Ethereum Legacy fee with 9 decimals WHEN dex bump applied THEN amount decimals are preserved`() {
val gasLimit = BigInteger.valueOf(21_000)
val gasPrice = BigInteger.valueOf(1_000_000) // 1 gwei in 9-decimal native units
// amount = 21_000 * 1_000_000 / 1e9 = 0.021
val initial = Fee.Ethereum.Legacy(
amount = ethAmount(BigDecimal("0.021"), decimals = 9),
gasLimit = gasLimit,
gasPrice = gasPrice,
)
val result = dexBump(TransactionFee.Single(normal = initial))
val patched = (result as TransactionFee.Single).normal as Fee.Ethereum.Legacy
assertThat(patched.amount.decimals).isEqualTo(9)
assertThat(patched.gasLimit).isEqualTo(BigInteger.valueOf(23_520)) // 21_000 * 112 / 100
}
// endregion
private fun ethAmount(value: BigDecimal, decimals: Int): Amount = Amount(
currencySymbol = "ETH",
value = value,
decimals = decimals,
)
}

View file

@ -0,0 +1,284 @@
package com.tangem.feature.swap.domain.fee
import com.google.common.truth.Truth.assertThat
import com.tangem.blockchain.common.Amount
import com.tangem.blockchain.common.transaction.Fee
import com.tangem.blockchain.common.transaction.TransactionFee
import com.tangem.domain.models.currency.CryptoCurrencyStatus
import com.tangem.domain.transaction.models.TransactionFeeExtended
import com.tangem.feature.swap.domain.models.ui.FeeBucket
import io.mockk.mockk
import org.junit.jupiter.api.Test
import org.junit.jupiter.api.TestInstance
import java.math.BigDecimal
import java.math.BigInteger
/**
* Unit tests for [SwapFeeFactory] ([REDACTED_TASK_KEY] Phase 3).
*
* Verifies the bucket [com.tangem.blockchain.common.transaction.Fee] mapping rules used by
* `SwapInteractorImpl.loadSwapFee` to assemble a `SwapFee` from a raw `TransactionFeeResult`.
*
* Golden mapping table must match `FeeItemConverter` in send-v2:
*
* | TransactionFee shape | FeeBucket | Selected Fee |
* |------------------------|--------------|-----------------------------------------|
* | Single(normal) | MARKET | normal |
* | Single(normal) | SLOW | normal (degraded no minimum) |
* | Single(normal) | FAST | normal (degraded no priority) |
* | Choosable(min/n/p) | SLOW | minimum |
* | Choosable(min/n/p) | MARKET | normal |
* | Choosable(min/n/p) | FAST | priority |
* | Choosable(min/n/p) | SUGGESTED | normal (caller overrides if applicable) |
* | Choosable(min/n/p) | CUSTOM | normal (caller overrides) |
*/
@TestInstance(TestInstance.Lifecycle.PER_CLASS)
internal class SwapFeeFactoryTest {
private val nativeFeeTokenStatus: CryptoCurrencyStatus = mockk(relaxed = true)
// -------------------------------------------------------------------------
// TransactionFee.Single
// -------------------------------------------------------------------------
@Test
fun `fromLoaded with Single picks the normal fee for MARKET bucket`() {
val singleFee = TransactionFee.Single(normal = ethLegacyFee(BigDecimal("0.002")))
val result = SwapFeeFactory.fromLoaded(
transactionFeeResult = TransactionFeeResult.Loaded(singleFee),
selectedFeeToken = nativeFeeTokenStatus,
feeBucket = FeeBucket.MARKET,
)
assertThat(result.fee).isEqualTo(singleFee.normal)
assertThat(result.feeBucket).isEqualTo(FeeBucket.MARKET)
assertThat(result.otherNativeFee).isEqualTo(BigDecimal.ZERO)
assertThat(result.selectedFeeToken).isSameInstanceAs(nativeFeeTokenStatus)
}
@Test
fun `fromLoaded with Single degrades SLOW bucket to normal fee`() {
val singleFee = TransactionFee.Single(normal = ethLegacyFee(BigDecimal("0.002")))
val result = SwapFeeFactory.fromLoaded(
transactionFeeResult = TransactionFeeResult.Loaded(singleFee),
selectedFeeToken = nativeFeeTokenStatus,
feeBucket = FeeBucket.SLOW,
)
assertThat(result.fee).isEqualTo(singleFee.normal)
assertThat(result.feeBucket).isEqualTo(FeeBucket.SLOW)
}
@Test
fun `fromLoaded with Single degrades FAST bucket to normal fee`() {
val singleFee = TransactionFee.Single(normal = ethLegacyFee(BigDecimal("0.002")))
val result = SwapFeeFactory.fromLoaded(
transactionFeeResult = TransactionFeeResult.Loaded(singleFee),
selectedFeeToken = nativeFeeTokenStatus,
feeBucket = FeeBucket.FAST,
)
assertThat(result.fee).isEqualTo(singleFee.normal)
assertThat(result.feeBucket).isEqualTo(FeeBucket.FAST)
}
// -------------------------------------------------------------------------
// TransactionFee.Choosable
// -------------------------------------------------------------------------
@Test
fun `fromLoaded with Choosable picks minimum fee for SLOW bucket`() {
val slow = ethLegacyFee(BigDecimal("0.001"))
val normal = ethLegacyFee(BigDecimal("0.002"))
val fast = ethLegacyFee(BigDecimal("0.003"))
val choosable = TransactionFee.Choosable(minimum = slow, normal = normal, priority = fast)
val result = SwapFeeFactory.fromLoaded(
transactionFeeResult = TransactionFeeResult.Loaded(choosable),
selectedFeeToken = nativeFeeTokenStatus,
feeBucket = FeeBucket.SLOW,
)
assertThat(result.fee).isEqualTo(slow)
}
@Test
fun `fromLoaded with Choosable picks normal fee for MARKET bucket`() {
val slow = ethLegacyFee(BigDecimal("0.001"))
val normal = ethLegacyFee(BigDecimal("0.002"))
val fast = ethLegacyFee(BigDecimal("0.003"))
val choosable = TransactionFee.Choosable(minimum = slow, normal = normal, priority = fast)
val result = SwapFeeFactory.fromLoaded(
transactionFeeResult = TransactionFeeResult.Loaded(choosable),
selectedFeeToken = nativeFeeTokenStatus,
feeBucket = FeeBucket.MARKET,
)
assertThat(result.fee).isEqualTo(normal)
}
@Test
fun `fromLoaded with Choosable picks priority fee for FAST bucket`() {
val slow = ethLegacyFee(BigDecimal("0.001"))
val normal = ethLegacyFee(BigDecimal("0.002"))
val fast = ethLegacyFee(BigDecimal("0.003"))
val choosable = TransactionFee.Choosable(minimum = slow, normal = normal, priority = fast)
val result = SwapFeeFactory.fromLoaded(
transactionFeeResult = TransactionFeeResult.Loaded(choosable),
selectedFeeToken = nativeFeeTokenStatus,
feeBucket = FeeBucket.FAST,
)
assertThat(result.fee).isEqualTo(fast)
}
@Test
fun `fromLoaded with Choosable falls back to normal fee for SUGGESTED bucket`() {
val slow = ethLegacyFee(BigDecimal("0.001"))
val normal = ethLegacyFee(BigDecimal("0.002"))
val fast = ethLegacyFee(BigDecimal("0.003"))
val choosable = TransactionFee.Choosable(minimum = slow, normal = normal, priority = fast)
val result = SwapFeeFactory.fromLoaded(
transactionFeeResult = TransactionFeeResult.Loaded(choosable),
selectedFeeToken = nativeFeeTokenStatus,
feeBucket = FeeBucket.SUGGESTED,
)
assertThat(result.fee).isEqualTo(normal)
assertThat(result.feeBucket).isEqualTo(FeeBucket.SUGGESTED)
}
@Test
fun `fromLoaded with Choosable falls back to normal fee for CUSTOM bucket`() {
val slow = ethLegacyFee(BigDecimal("0.001"))
val normal = ethLegacyFee(BigDecimal("0.002"))
val fast = ethLegacyFee(BigDecimal("0.003"))
val choosable = TransactionFee.Choosable(minimum = slow, normal = normal, priority = fast)
val result = SwapFeeFactory.fromLoaded(
transactionFeeResult = TransactionFeeResult.Loaded(choosable),
selectedFeeToken = nativeFeeTokenStatus,
feeBucket = FeeBucket.CUSTOM,
)
assertThat(result.fee).isEqualTo(normal)
assertThat(result.feeBucket).isEqualTo(FeeBucket.CUSTOM)
}
// -------------------------------------------------------------------------
// LoadedExtended (gasless / token fee)
// -------------------------------------------------------------------------
@Test
fun `fromLoadedExtended picks normal fee from transactionFeeExtended for MARKET`() {
val rawFee = ethLegacyFee(BigDecimal("0.002"))
val txFee = TransactionFee.Single(normal = rawFee)
val extended = mockk<TransactionFeeExtended>(relaxed = true) {
io.mockk.every { transactionFee } returns txFee
}
val result = SwapFeeFactory.fromLoadedExtended(
transactionFeeResult = TransactionFeeResult.LoadedExtended(extended),
selectedFeeToken = nativeFeeTokenStatus,
feeBucket = FeeBucket.MARKET,
)
assertThat(result.fee).isEqualTo(rawFee)
assertThat(result.transactionFeeResult).isInstanceOf(TransactionFeeResult.LoadedExtended::class.java)
}
// -------------------------------------------------------------------------
// otherNativeFee propagation
// -------------------------------------------------------------------------
@Test
fun `otherNativeFee is propagated verbatim into SwapFee`() {
val singleFee = TransactionFee.Single(normal = ethLegacyFee(BigDecimal("0.002")))
val bridgeFee = BigDecimal("0.5")
val result = SwapFeeFactory.fromLoaded(
transactionFeeResult = TransactionFeeResult.Loaded(singleFee),
selectedFeeToken = nativeFeeTokenStatus,
otherNativeFee = bridgeFee,
)
assertThat(result.otherNativeFee).isEquivalentAccordingToCompareTo(bridgeFee)
}
@Test
fun `default otherNativeFee is ZERO`() {
val singleFee = TransactionFee.Single(normal = ethLegacyFee(BigDecimal("0.002")))
val result = SwapFeeFactory.fromLoaded(
transactionFeeResult = TransactionFeeResult.Loaded(singleFee),
selectedFeeToken = nativeFeeTokenStatus,
)
assertThat(result.otherNativeFee).isEqualTo(BigDecimal.ZERO)
}
// -------------------------------------------------------------------------
// from() generic dispatcher
// -------------------------------------------------------------------------
@Test
fun `from dispatches Loaded to fromLoaded`() {
val rawFee = ethLegacyFee(BigDecimal("0.002"))
val transactionFeeResult = TransactionFeeResult.Loaded(TransactionFee.Single(normal = rawFee))
val result = SwapFeeFactory.from(
transactionFeeResult = transactionFeeResult,
selectedFeeToken = nativeFeeTokenStatus,
)
assertThat(result.fee).isEqualTo(rawFee)
assertThat(result.transactionFeeResult).isSameInstanceAs(transactionFeeResult)
}
@Test
fun `from dispatches LoadedExtended to fromLoadedExtended`() {
val rawFee = ethLegacyFee(BigDecimal("0.002"))
val txFee = TransactionFee.Single(normal = rawFee)
val extended = mockk<TransactionFeeExtended>(relaxed = true) {
io.mockk.every { transactionFee } returns txFee
}
val transactionFeeResult = TransactionFeeResult.LoadedExtended(extended)
val result = SwapFeeFactory.from(
transactionFeeResult = transactionFeeResult,
selectedFeeToken = nativeFeeTokenStatus,
)
assertThat(result.fee).isEqualTo(rawFee)
assertThat(result.transactionFeeResult).isSameInstanceAs(transactionFeeResult)
}
// -------------------------------------------------------------------------
// FeeBucket.toAnalyticsName labels
// -------------------------------------------------------------------------
@Test
fun `FeeBucket toAnalyticsName returns labels compatible with legacy FeeType`() {
// SLOW didn't exist in the legacy FeeType; new label is "Min".
assertThat(FeeBucket.SLOW.toAnalyticsName()).isEqualTo("Min")
// MARKET corresponds to legacy FeeType.NORMAL.getNameForAnalytics() == "Normal".
assertThat(FeeBucket.MARKET.toAnalyticsName()).isEqualTo("Normal")
// FAST corresponds to legacy FeeType.PRIORITY.getNameForAnalytics() == "Max".
assertThat(FeeBucket.FAST.toAnalyticsName()).isEqualTo("Max")
assertThat(FeeBucket.SUGGESTED.toAnalyticsName()).isEqualTo("Suggested")
assertThat(FeeBucket.CUSTOM.toAnalyticsName()).isEqualTo("Custom")
}
// -------------------------------------------------------------------------
// Helpers
// -------------------------------------------------------------------------
private fun ethLegacyFee(value: BigDecimal): Fee.Ethereum.Legacy = Fee.Ethereum.Legacy(
amount = Amount(currencySymbol = "ETH", value = value, decimals = 18),
gasLimit = BigInteger.valueOf(100_000),
gasPrice = BigInteger.valueOf(20_000_000_000),
)
}

View file

@ -1,7 +1,11 @@
package com.tangem.feature.swap.domain.transfer
import arrow.core.left
import arrow.core.right
import com.google.common.truth.Truth.assertThat
import com.tangem.blockchain.common.TransactionData
import com.tangem.blockchain.common.transaction.Fee
import com.tangem.blockchain.common.transaction.TransactionFee
import com.tangem.domain.account.status.usecase.IsAccountsModeEnabledUseCase
import com.tangem.domain.appcurrency.GetSelectedAppCurrencyUseCase
import com.tangem.domain.appcurrency.model.AppCurrency
@ -9,8 +13,18 @@ import com.tangem.domain.balancehiding.GetBalanceHidingSettingsUseCase
import com.tangem.domain.models.currency.CryptoCurrency
import com.tangem.domain.models.currency.CryptoCurrencyStatus
import com.tangem.domain.models.network.Network
import com.tangem.domain.models.network.NetworkAddress
import com.tangem.domain.models.wallet.UserWallet
import com.tangem.domain.models.wallet.UserWalletId
import com.tangem.domain.swap.models.SwapCurrencyStatus
import com.tangem.domain.transaction.error.SendTransactionError
import com.tangem.domain.transaction.models.TransactionFeeExtended
import com.tangem.domain.transaction.usecase.CreateTransferTransactionUseCase
import com.tangem.domain.transaction.usecase.GetFeeUseCase
import com.tangem.domain.transaction.usecase.SendTransactionUseCase
import com.tangem.domain.transaction.usecase.gasless.CreateAndSendGaslessTransactionUseCase
import com.tangem.domain.transaction.usecase.gasless.GetFeeForGaslessUseCase
import com.tangem.feature.swap.domain.fee.TransactionFeeResult
import com.tangem.feature.swap.domain.models.SwapAmount
import com.tangem.feature.swap.domain.models.ui.SwapState
import com.tangem.feature.swap.domain.models.ui.TokenSwapInfo
@ -32,12 +46,22 @@ internal class SwapTransferInteractorImplTest {
private val getSelectedAppCurrencyUseCase: GetSelectedAppCurrencyUseCase = mockk()
private val getBalanceHidingSettingsUseCase: GetBalanceHidingSettingsUseCase = mockk()
private val isAccountsModeEnabledUseCase: IsAccountsModeEnabledUseCase = mockk()
private val getFeeUseCase: GetFeeUseCase = mockk()
private val getFeeForGaslessUseCase: GetFeeForGaslessUseCase = mockk()
private val createTransferTransactionUseCase: CreateTransferTransactionUseCase = mockk()
private val sendTransactionUseCase: SendTransactionUseCase = mockk()
private val createAndSendGaslessTransactionUseCase: CreateAndSendGaslessTransactionUseCase = mockk()
private val sut = SwapTransferInteractorImpl(
swapFeatureToggles = swapFeatureToggles,
getSelectedAppCurrencyUseCase = getSelectedAppCurrencyUseCase,
getBalanceHidingSettingsUseCase = getBalanceHidingSettingsUseCase,
isAccountsModeEnabledUseCase = isAccountsModeEnabledUseCase,
getFeeUseCase = getFeeUseCase,
getFeeForGaslessUseCase = getFeeForGaslessUseCase,
createTransferTransactionUseCase = createTransferTransactionUseCase,
sendTransactionUseCase = sendTransactionUseCase,
createAndSendGaslessTransactionUseCase = createAndSendGaslessTransactionUseCase,
)
@AfterEach
@ -176,6 +200,367 @@ internal class SwapTransferInteractorImplTest {
// endregion
// region loadFee
@Test
fun `GIVEN valid amount and destination WHEN loadFee THEN return TransactionFee from use case`() = runTest {
val userWallet: UserWallet = mockk()
val fromCurrencyStatus = buildCurrencyStatus(
rawCurrencyId = FROM_RAW_CURRENCY_ID,
decimals = FROM_DECIMALS,
userWallet = userWallet,
)
val toCurrencyStatus = buildCurrencyStatus(
rawCurrencyId = TO_RAW_CURRENCY_ID,
decimals = TO_DECIMALS,
destinationAddress = DESTINATION_ADDRESS,
)
val transactionFee: TransactionFee = mockk()
coEvery {
getFeeUseCase(
amount = BigDecimal("1.5"),
destination = DESTINATION_ADDRESS,
userWallet = userWallet,
cryptoCurrency = fromCurrencyStatus.currency,
)
} returns transactionFee.right()
val result = sut.loadFee(
fromSwapCurrencyStatus = fromCurrencyStatus,
toSwapCurrencyStatus = toCurrencyStatus,
fromTokenAmount = "1.5",
)
assertThat(result).isEqualTo(transactionFee.right())
coVerify {
getFeeUseCase(
amount = BigDecimal("1.5"),
destination = DESTINATION_ADDRESS,
userWallet = userWallet,
cryptoCurrency = fromCurrencyStatus.currency,
)
}
}
// endregion
// region loadFeeExtended
@Test
fun `GIVEN valid amount and destination WHEN loadFeeExtended THEN return TransactionFeeExtended`() = runTest {
val userWalletId: UserWalletId = mockk()
val userWallet: UserWallet = mockk { every { walletId } returns userWalletId }
val network: Network = mockk()
val fromCurrencyStatus = buildCurrencyStatus(
rawCurrencyId = FROM_RAW_CURRENCY_ID,
decimals = FROM_DECIMALS,
userWallet = userWallet,
network = network,
)
val toCurrencyStatus = buildCurrencyStatus(
rawCurrencyId = TO_RAW_CURRENCY_ID,
decimals = TO_DECIMALS,
destinationAddress = DESTINATION_ADDRESS,
)
val transactionData: TransactionData.Uncompiled = mockk()
val feeExtended: TransactionFeeExtended = mockk()
coEvery {
createTransferTransactionUseCase(
amount = any(),
memo = null,
destination = DESTINATION_ADDRESS,
userWalletId = userWalletId,
network = network,
)
} returns transactionData.right()
coEvery {
getFeeForGaslessUseCase(
userWallet = userWallet,
network = network,
transactionData = transactionData,
)
} returns feeExtended.right()
val result = sut.loadFeeExtended(
fromSwapCurrencyStatus = fromCurrencyStatus,
toSwapCurrencyStatus = toCurrencyStatus,
fromTokenAmount = "2.0",
)
assertThat(result).isEqualTo(feeExtended.right())
coVerify {
createTransferTransactionUseCase(
amount = any(),
memo = null,
destination = DESTINATION_ADDRESS,
userWalletId = userWalletId,
network = network,
)
}
coVerify {
getFeeForGaslessUseCase(
userWallet = userWallet,
network = network,
transactionData = transactionData,
)
}
}
// endregion
// region sendTransfer
@Test
fun `GIVEN unparsable amount WHEN sendTransfer THEN return DataError`() = runTest {
val fromCurrencyStatus = buildCurrencyStatus(
rawCurrencyId = FROM_RAW_CURRENCY_ID,
decimals = FROM_DECIMALS,
)
val toCurrencyStatus = buildCurrencyStatus(
rawCurrencyId = TO_RAW_CURRENCY_ID,
decimals = TO_DECIMALS,
destinationAddress = DESTINATION_ADDRESS,
)
val result = sut.sendTransfer(
fromSwapCurrencyStatus = fromCurrencyStatus,
toSwapCurrencyStatus = toCurrencyStatus,
fromTokenAmount = "abc",
fee = mockk(),
transactionFeeResult = mockk(),
)
assertThat(result).isInstanceOf(arrow.core.Either.Left::class.java)
val error = (result as arrow.core.Either.Left).value
assertThat(error).isInstanceOf(SendTransactionError.DataError::class.java)
}
@Test
fun `GIVEN missing destination WHEN sendTransfer THEN return DataError`() = runTest {
val fromCurrencyStatus = buildCurrencyStatus(
rawCurrencyId = FROM_RAW_CURRENCY_ID,
decimals = FROM_DECIMALS,
)
val toCurrencyStatus = buildCurrencyStatus(
rawCurrencyId = TO_RAW_CURRENCY_ID,
decimals = TO_DECIMALS,
destinationAddress = null,
)
val result = sut.sendTransfer(
fromSwapCurrencyStatus = fromCurrencyStatus,
toSwapCurrencyStatus = toCurrencyStatus,
fromTokenAmount = "1.0",
fee = mockk(),
transactionFeeResult = mockk(),
)
assertThat(result).isInstanceOf(arrow.core.Either.Left::class.java)
}
@Test
fun `GIVEN coin and Loaded fee WHEN sendTransfer THEN forward tx hash from sendTransactionUseCase`() = runTest {
val userWalletId: UserWalletId = mockk()
val userWallet: UserWallet = mockk { every { walletId } returns userWalletId }
val network: Network = mockk()
val fromCurrencyStatus = buildCurrencyStatus(
rawCurrencyId = FROM_RAW_CURRENCY_ID,
decimals = FROM_DECIMALS,
userWallet = userWallet,
network = network,
)
val toCurrencyStatus = buildCurrencyStatus(
rawCurrencyId = TO_RAW_CURRENCY_ID,
decimals = TO_DECIMALS,
destinationAddress = DESTINATION_ADDRESS,
)
val fee: Fee = mockk()
val txData: TransactionData.Uncompiled = mockk()
val transactionFeeResult = TransactionFeeResult.Loaded(mockk())
coEvery {
createTransferTransactionUseCase(
amount = any(),
fee = fee,
memo = null,
destination = DESTINATION_ADDRESS,
userWalletId = userWalletId,
network = network,
)
} returns txData.right()
coEvery {
sendTransactionUseCase(txData = txData, userWallet = userWallet, network = network)
} returns TX_HASH.right()
val result = sut.sendTransfer(
fromSwapCurrencyStatus = fromCurrencyStatus,
toSwapCurrencyStatus = toCurrencyStatus,
fromTokenAmount = "1.0",
fee = fee,
transactionFeeResult = transactionFeeResult,
)
assertThat(result).isEqualTo(TX_HASH.right())
coVerify {
sendTransactionUseCase(txData = txData, userWallet = userWallet, network = network)
}
coVerify(exactly = 0) {
createAndSendGaslessTransactionUseCase(any(), any(), any())
}
}
@Test
fun `GIVEN token and LoadedExtended fee WHEN sendTransfer THEN route via createAndSendGaslessTransactionUseCase`() =
runTest {
val userWalletId: UserWalletId = mockk()
val userWallet: UserWallet = mockk { every { walletId } returns userWalletId }
val network: Network = mockk()
val fromCurrencyStatus = buildTokenCurrencyStatus(
rawCurrencyId = FROM_RAW_CURRENCY_ID,
decimals = FROM_DECIMALS,
userWallet = userWallet,
network = network,
)
val toCurrencyStatus = buildTokenCurrencyStatus(
rawCurrencyId = TO_RAW_CURRENCY_ID,
decimals = TO_DECIMALS,
destinationAddress = DESTINATION_ADDRESS,
)
val fee: Fee = mockk()
val txData: TransactionData.Uncompiled = mockk()
val transactionFeeExtended: TransactionFeeExtended = mockk()
val transactionFeeResult = TransactionFeeResult.LoadedExtended(transactionFeeExtended)
coEvery {
createTransferTransactionUseCase(
amount = any(),
fee = fee,
memo = null,
destination = DESTINATION_ADDRESS,
userWalletId = userWalletId,
network = network,
)
} returns txData.right()
coEvery {
createAndSendGaslessTransactionUseCase(
userWallet = userWallet,
transactionData = txData,
fee = transactionFeeExtended,
)
} returns TX_HASH.right()
val result = sut.sendTransfer(
fromSwapCurrencyStatus = fromCurrencyStatus,
toSwapCurrencyStatus = toCurrencyStatus,
fromTokenAmount = "1.0",
fee = fee,
transactionFeeResult = transactionFeeResult,
)
assertThat(result).isEqualTo(TX_HASH.right())
coVerify {
createAndSendGaslessTransactionUseCase(
userWallet = userWallet,
transactionData = txData,
fee = transactionFeeExtended,
)
}
coVerify(exactly = 0) {
sendTransactionUseCase(txData = any(), userWallet = any(), network = any())
}
}
@Test
fun `GIVEN token and Loaded fee WHEN sendTransfer THEN fall back to sendTransactionUseCase`() = runTest {
val userWalletId: UserWalletId = mockk()
val userWallet: UserWallet = mockk { every { walletId } returns userWalletId }
val network: Network = mockk()
val fromCurrencyStatus = buildTokenCurrencyStatus(
rawCurrencyId = FROM_RAW_CURRENCY_ID,
decimals = FROM_DECIMALS,
userWallet = userWallet,
network = network,
)
val toCurrencyStatus = buildTokenCurrencyStatus(
rawCurrencyId = TO_RAW_CURRENCY_ID,
decimals = TO_DECIMALS,
destinationAddress = DESTINATION_ADDRESS,
)
val fee: Fee = mockk()
val txData: TransactionData.Uncompiled = mockk()
val transactionFeeResult = TransactionFeeResult.Loaded(mockk())
coEvery {
createTransferTransactionUseCase(
amount = any(),
fee = fee,
memo = null,
destination = DESTINATION_ADDRESS,
userWalletId = userWalletId,
network = network,
)
} returns txData.right()
coEvery {
sendTransactionUseCase(txData = txData, userWallet = userWallet, network = network)
} returns TX_HASH.right()
val result = sut.sendTransfer(
fromSwapCurrencyStatus = fromCurrencyStatus,
toSwapCurrencyStatus = toCurrencyStatus,
fromTokenAmount = "1.0",
fee = fee,
transactionFeeResult = transactionFeeResult,
)
assertThat(result).isEqualTo(TX_HASH.right())
coVerify {
sendTransactionUseCase(txData = txData, userWallet = userWallet, network = network)
}
coVerify(exactly = 0) {
createAndSendGaslessTransactionUseCase(any(), any(), any())
}
}
@Test
fun `GIVEN createTransferTransactionUseCase fails WHEN sendTransfer THEN return DataError`() = runTest {
val userWalletId: UserWalletId = mockk()
val userWallet: UserWallet = mockk { every { walletId } returns userWalletId }
val network: Network = mockk()
val fromCurrencyStatus = buildCurrencyStatus(
rawCurrencyId = FROM_RAW_CURRENCY_ID,
decimals = FROM_DECIMALS,
userWallet = userWallet,
network = network,
)
val toCurrencyStatus = buildCurrencyStatus(
rawCurrencyId = TO_RAW_CURRENCY_ID,
decimals = TO_DECIMALS,
destinationAddress = DESTINATION_ADDRESS,
)
val fee: Fee = mockk()
coEvery {
createTransferTransactionUseCase(
amount = any(),
fee = fee,
memo = null,
destination = DESTINATION_ADDRESS,
userWalletId = userWalletId,
network = network,
)
} returns IllegalStateException("boom").left()
val result = sut.sendTransfer(
fromSwapCurrencyStatus = fromCurrencyStatus,
toSwapCurrencyStatus = toCurrencyStatus,
fromTokenAmount = "1.0",
fee = fee,
transactionFeeResult = mockk(),
)
assertThat(result).isInstanceOf(arrow.core.Either.Left::class.java)
val error = (result as arrow.core.Either.Left).value
assertThat(error).isInstanceOf(SendTransactionError.DataError::class.java)
}
// endregion
// region shouldTransferInsteadOfSwap
@Test
@ -300,6 +685,9 @@ internal class SwapTransferInteractorImplTest {
fiatRate: BigDecimal = BigDecimal.ZERO,
amount: BigDecimal = BigDecimal.ZERO,
userWallet: UserWallet = mockk(),
destinationAddress: String? = null,
symbol: String = "ETH",
network: Network = mockk(),
): SwapCurrencyStatus {
val currencyId: CryptoCurrency.ID = mockk {
every { this@mockk.rawCurrencyId } returns rawCurrencyId
@ -307,13 +695,60 @@ internal class SwapTransferInteractorImplTest {
val currency: CryptoCurrency.Coin = mockk {
every { this@mockk.id } returns currencyId
every { this@mockk.decimals } returns decimals
every { this@mockk.symbol } returns symbol
every { this@mockk.network } returns network
}
val networkAddress = destinationAddress?.let {
NetworkAddress.Single(NetworkAddress.Address(value = it, type = NetworkAddress.Address.Type.Primary))
}
val currencyValue: CryptoCurrencyStatus.Value = mockk {
every { this@mockk.fiatRate } returns fiatRate
every { this@mockk.networkAddress } returns networkAddress
every { this@mockk.yieldSupplyStatus } returns null
every { this@mockk.amount } returns amount
}
val status: CryptoCurrencyStatus = mockk {
every { this@mockk.value } returns currencyValue
every { this@mockk.currency } returns currency
}
return mockk {
every { this@mockk.currency } returns currency
every { this@mockk.userWallet } returns userWallet
every { this@mockk.status } returns status
}
}
@Suppress("LongParameterList")
private fun buildTokenCurrencyStatus(
rawCurrencyId: CryptoCurrency.RawID?,
decimals: Int,
userWallet: UserWallet = mockk(),
destinationAddress: String? = null,
symbol: String = "USDT",
network: Network = mockk(),
): SwapCurrencyStatus {
val currencyId: CryptoCurrency.ID = mockk {
every { this@mockk.rawCurrencyId } returns rawCurrencyId
}
val currency: CryptoCurrency.Token = mockk {
every { this@mockk.id } returns currencyId
every { this@mockk.decimals } returns decimals
every { this@mockk.symbol } returns symbol
every { this@mockk.network } returns network
every { this@mockk.contractAddress } returns CONTRACT_ADDRESS
}
val networkAddress = destinationAddress?.let {
NetworkAddress.Single(NetworkAddress.Address(value = it, type = NetworkAddress.Address.Type.Primary))
}
val currencyValue: CryptoCurrencyStatus.Value = mockk {
every { this@mockk.fiatRate } returns BigDecimal.ZERO
every { this@mockk.networkAddress } returns networkAddress
every { this@mockk.yieldSupplyStatus } returns null
every { this@mockk.amount } returns BigDecimal.ZERO
}
val status: CryptoCurrencyStatus = mockk {
every { this@mockk.value } returns currencyValue
every { this@mockk.currency } returns currency
}
return mockk {
every { this@mockk.currency } returns currency
@ -329,6 +764,9 @@ internal class SwapTransferInteractorImplTest {
const val POLYGON = "polygon"
const val USDT_CONTRACT = "0xdAC17F958D2ee523a2206206994597C13D831ec7"
const val USDC_CONTRACT = "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48"
const val DESTINATION_ADDRESS = "0xdEaDBeEf00000000000000000000000000000001"
const val CONTRACT_ADDRESS = "0xCONTRACT00000000000000000000000000000001"
const val TX_HASH = "0xabc123"
const val FROM_DECIMALS = 18
const val TO_DECIMALS = 6
val USD_QUOTE: BigDecimal = BigDecimal("2000")

View file

@ -29,6 +29,7 @@ import com.tangem.core.ui.utils.parseBigDecimalOrNull
import com.tangem.domain.models.currency.CryptoCurrencyStatus
import com.tangem.domain.models.wallet.isHotWallet
import com.tangem.feature.swap.component.SwapFeeSelectorBlockComponent
import com.tangem.feature.swap.domain.models.ui.PermissionDataState
import com.tangem.feature.swap.model.SwapModel
import com.tangem.feature.swap.models.SwapPermissionUM
import com.tangem.feature.swap.router.SwapRoute
@ -152,7 +153,10 @@ internal class DefaultSwapComponent @AssistedInject constructor(
val feePaidCryptoCurrency by remember { derivedStateOf { dataState.feePaidCryptoCurrency } }
val shouldHideBlock by remember {
derivedStateOf {
dataState.amount?.parseBigDecimalOrNull().isNullOrZero() || model.uiState.isInsufficientFunds
dataState.amount?.parseBigDecimalOrNull().isNullOrZero() ||
model.uiState.isInsufficientFunds ||
dataState.selectedProvider == null ||
dataState.getCurrentLoadedSwapState()?.permissionState !is PermissionDataState.Empty
}
}

View file

@ -14,7 +14,7 @@ import com.tangem.core.analytics.models.AppsFlyerIncludedEvent
import com.tangem.core.analytics.models.getReferralParams
import com.tangem.domain.models.currency.CryptoCurrency
import com.tangem.feature.swap.domain.models.domain.SwapProvider
import com.tangem.feature.swap.domain.models.ui.FeeType
import com.tangem.feature.swap.domain.models.ui.FeeBucket
private const val SWAP_CATEGORY = "Swap"
private const val PROMO_CATEGORY = "Promo"
@ -98,7 +98,7 @@ sealed class SwapEvents(
@Suppress("NullableToStringCall", "LongParameterList")
class SwapInProgressScreen(
val provider: SwapProvider,
val commission: FeeType, // Market / Fast
val commission: FeeBucket, // SLOW / MARKET / FAST / SUGGESTED / CUSTOM
val sendBlockchain: String,
val receiveBlockchain: String,
val sendToken: String,
@ -112,7 +112,7 @@ sealed class SwapEvents(
event = "Swap in Progress Screen Opened",
params = buildMap {
put("Provider", provider.name)
put("Commission", if (commission == FeeType.NORMAL) "Market" else "Fast")
put("Commission", if (commission == FeeBucket.MARKET) "Market" else "Fast")
put("Send Token", sendToken)
put("Receive Token", receiveToken)
put("Send Blockchain", sendBlockchain)
@ -190,7 +190,6 @@ sealed class SwapEvents(
val sendBlockchain: String,
val receiveBlockchain: String,
val providerName: String,
) : SwapEvents(
event = "Notice - Trade too large",
params = mapOf(

View file

@ -1,208 +0,0 @@
package com.tangem.feature.swap.converters
import com.tangem.common.getTotalCryptoAmount
import com.tangem.common.getTotalFiatAmount
import com.tangem.common.ui.R
import com.tangem.common.ui.account.AccountCryptoPortfolioItemStateConverter
import com.tangem.common.ui.account.TokensListPortfolioItemConverter
import com.tangem.common.ui.account.toUM
import com.tangem.common.ui.tokens.TokenItemStateConverter
import com.tangem.common.ui.tokens.TokenItemStateConverter.Companion.isFlickering
import com.tangem.core.ui.components.currency.icon.CurrencyIconState
import com.tangem.common.ui.components.currency.icon.converter.CryptoCurrencyToIconStateConverter
import com.tangem.core.ui.components.token.state.TokenItemState
import com.tangem.core.ui.components.token.state.TokenItemState.FiatAmountState
import com.tangem.core.ui.components.tokenlist.state.TokensListItemUM
import com.tangem.core.ui.extensions.TextReference
import com.tangem.core.ui.extensions.pluralReference
import com.tangem.core.ui.extensions.stringReference
import com.tangem.core.ui.extensions.wrappedList
import com.tangem.core.ui.format.bigdecimal.crypto
import com.tangem.core.ui.format.bigdecimal.fiat
import com.tangem.core.ui.format.bigdecimal.format
import com.tangem.domain.appcurrency.model.AppCurrency
import com.tangem.domain.models.StatusSource
import com.tangem.domain.models.TotalFiatBalance
import com.tangem.domain.models.account.Account
import com.tangem.domain.models.account.AccountId
import com.tangem.domain.models.currency.CryptoCurrencyStatus
import com.tangem.feature.swap.domain.models.ui.AccountSwapAvailability
import com.tangem.utils.converter.Converter
import com.tangem.utils.extensions.orZero
import kotlinx.collections.immutable.toPersistentList
internal class AccountTokenItemConverter(
private val appCurrency: AppCurrency,
private val unavailableErrorText: TextReference,
private val expandedAccounts: Map<AccountId, Boolean>,
private val onTokenItemClick: (Account, CryptoCurrencyStatus) -> Unit,
private val onAccountItemClick: (Account) -> Unit,
) : Converter<AccountSwapAvailability, TokensListItemUM.Portfolio> {
override fun convert(value: AccountSwapAvailability): TokensListItemUM.Portfolio {
val headerTokenItemState = when (val account = value.account) {
is Account.CryptoPortfolio -> AccountCryptoPortfolioItemStateConverter(
appCurrency = appCurrency,
account = account.copy(cryptoCurrencies = value.currencyList.map { it.cryptoCurrencyStatus.currency }),
onItemClick = onAccountItemClick,
).convert(
TotalFiatBalance.Loaded(
amount = value.currencyList.sumOf { it.cryptoCurrencyStatus.value.fiatAmount.orZero() },
source = StatusSource.ONLY_CACHE,
),
)
is Account.Payment -> createPaymentAccountHeaderState(value)
}
return TokensListPortfolioItemConverter(
tokenItemUM = headerTokenItemState,
isExpanded = expandedAccounts[value.account.accountId] != false,
isCollapsable = true,
tokens = value.currencyList.map { accountSwapCurrency ->
createAvailableItemConverter(value.account)
.convert(accountSwapCurrency.cryptoCurrencyStatus)
}.map(TokensListItemUM::Token).toPersistentList(),
).convert(Unit)
}
private fun createPaymentAccountHeaderState(accountSwapAvailability: AccountSwapAvailability): TokenItemState {
val account = accountSwapAvailability.account
val tokensCount = accountSwapAvailability.currencyList.size
val fiatBalance =
accountSwapAvailability.currencyList.sumOf { it.cryptoCurrencyStatus.value.fiatAmount.orZero() }
return TokenItemState.Content(
id = account.accountId.value,
iconState = CurrencyIconState.PaymentAccount(),
titleState = TokenItemState.TitleState.Content(text = account.accountName.toUM().value),
subtitleState = TokenItemState.SubtitleState.TextContent(
value = pluralReference(
R.plurals.common_tokens_count,
count = tokensCount,
formatArgs = wrappedList(tokensCount),
),
isAvailable = false,
),
onItemClick = { onAccountItemClick(account) },
fiatAmountState = FiatAmountState.Content(
text = fiatBalance.format {
fiat(
fiatCurrencyCode = appCurrency.code,
fiatCurrencySymbol = appCurrency.symbol,
)
},
isFlickering = false,
),
subtitle2State = null,
onItemLongClick = null,
)
}
fun createAvailableItemConverter(account: Account): TokenItemStateConverter {
return TokenItemStateConverter(
appCurrency = appCurrency,
subtitleStateProvider = { status ->
createSubtitleState(
status = status,
isAvailable = true,
text = stringReference(value = status.currency.symbol),
)
},
subtitle2StateProvider = ::createSubtitle2State,
fiatAmountStateProvider = {
createFiatAmountStateProvider(status = it, appCurrency = appCurrency, isAvailable = true)
},
onItemClick = { _, currencyStatus -> onTokenItemClick(account, currencyStatus) },
)
}
fun createUnavailableItemConverter(): TokenItemStateConverter {
return TokenItemStateConverter(
appCurrency = appCurrency,
iconStateProvider = { CryptoCurrencyToIconStateConverter(isAvailable = false).convert(it) },
titleStateProvider = { status ->
TokenItemState.TitleState.Content(
text = stringReference(value = status.currency.name),
isAvailable = false,
)
},
subtitleStateProvider = { status ->
createSubtitleState(
status = status,
isAvailable = false,
text = unavailableErrorText,
)
},
subtitle2StateProvider = ::createSubtitle2State,
fiatAmountStateProvider = {
createFiatAmountStateProvider(status = it, appCurrency = appCurrency, isAvailable = false)
},
)
}
private fun createSubtitleState(
status: CryptoCurrencyStatus,
isAvailable: Boolean,
text: TextReference,
): TokenItemState.SubtitleState {
return when (status.value) {
CryptoCurrencyStatus.Loading -> TokenItemState.SubtitleState.Loading
else -> {
TokenItemState.SubtitleState.TextContent(
value = text,
isAvailable = isAvailable,
)
}
}
}
private fun createSubtitle2State(status: CryptoCurrencyStatus): TokenItemState.Subtitle2State? {
return when (status.value) {
is CryptoCurrencyStatus.Loaded,
is CryptoCurrencyStatus.Custom,
is CryptoCurrencyStatus.NoQuote,
is CryptoCurrencyStatus.NoAccount,
-> {
TokenItemState.Subtitle2State.TextContent(
text = status.getTotalCryptoAmount().format {
crypto(cryptoCurrency = status.currency)
},
isFlickering = status.value.isFlickering(),
)
}
is CryptoCurrencyStatus.Loading,
is CryptoCurrencyStatus.MissedDerivation,
is CryptoCurrencyStatus.Unreachable,
is CryptoCurrencyStatus.NoAmount,
-> null
}
}
private fun createFiatAmountStateProvider(
status: CryptoCurrencyStatus,
appCurrency: AppCurrency,
isAvailable: Boolean,
): FiatAmountState? {
return when (status.value) {
is CryptoCurrencyStatus.Loaded,
is CryptoCurrencyStatus.Custom,
is CryptoCurrencyStatus.NoQuote,
is CryptoCurrencyStatus.NoAccount,
-> {
FiatAmountState.TextContent(
text = status.getTotalFiatAmount().format {
fiat(
fiatCurrencyCode = appCurrency.code,
fiatCurrencySymbol = appCurrency.symbol,
)
},
isAvailable = isAvailable,
isFlickering = status.value.isFlickering(),
)
}
is CryptoCurrencyStatus.Unreachable,
is CryptoCurrencyStatus.NoAmount,
is CryptoCurrencyStatus.MissedDerivation,
is CryptoCurrencyStatus.Loading,
-> null
}
}
}

View file

@ -6,29 +6,35 @@ import com.tangem.common.routing.AppRouter
import com.tangem.common.ui.notifications.NotificationUM
import com.tangem.common.ui.notifications.NotificationsFactory.addDustWarningNotification
import com.tangem.common.ui.notifications.NotificationsFactory.addExistentialWarningNotification
import com.tangem.common.ui.notifications.NotificationsFactory.addFeeUnreachableNotification
import com.tangem.common.ui.notifications.NotificationsFactory.addReserveAmountErrorNotification
import com.tangem.common.ui.notifications.NotificationsFactory.addTransactionLimitErrorNotification
import com.tangem.common.ui.notifications.NotificationsFactory.addValidateTransactionNotifications
import com.tangem.core.ui.extensions.TextReference
import com.tangem.core.ui.format.bigdecimal.crypto
import com.tangem.core.ui.format.bigdecimal.fiat
import com.tangem.core.ui.format.bigdecimal.format
import com.tangem.core.ui.utils.parseBigDecimal
import com.tangem.domain.appcurrency.model.AppCurrency
import com.tangem.domain.express.models.ExpressError
import com.tangem.domain.models.currency.CryptoCurrency
import com.tangem.domain.models.currency.CryptoCurrencyStatus
import com.tangem.domain.transaction.error.GetFeeError
import com.tangem.domain.transaction.usecase.gasless.IsGaslessFeeSupportedForNetwork
import com.tangem.feature.swap.domain.models.ExpressDataError
import com.tangem.feature.swap.domain.models.SwapAmount
import com.tangem.feature.swap.domain.models.domain.ExchangeProviderType
import com.tangem.feature.swap.domain.models.domain.IncludeFeeInAmount
import com.tangem.feature.swap.domain.models.domain.SwapFeeState
import com.tangem.feature.swap.domain.models.ui.*
import com.tangem.feature.swap.domain.models.domain.SwapBalanceStatus
import com.tangem.feature.swap.domain.models.ui.PermissionDataState
import com.tangem.feature.swap.domain.models.ui.PriceImpact
import com.tangem.feature.swap.domain.models.ui.SwapFee
import com.tangem.feature.swap.domain.models.ui.SwapState
import com.tangem.feature.swap.models.UiActions
import com.tangem.feature.swap.models.states.FeeItemState
import com.tangem.feature.swap.models.states.SwapNotificationUM
import com.tangem.lib.crypto.BlockchainUtils
import com.tangem.lib.crypto.BlockchainUtils.getTezosThreshold
import com.tangem.lib.crypto.BlockchainUtils.isTezos
import com.tangem.utils.Provider
import com.tangem.utils.extensions.orZero
import kotlinx.collections.immutable.ImmutableList
import kotlinx.collections.immutable.persistentListOf
@ -39,6 +45,7 @@ import java.math.BigDecimal
internal class SwapNotificationsFactory(
private val actions: UiActions,
private val isGaslessFeeSupportedForNetwork: IsGaslessFeeSupportedForNetwork,
private val appCurrencyProvider: Provider<AppCurrency> = Provider { AppCurrency.Default },
) {
fun getGeneralErrorStateNotifications(
@ -74,18 +81,13 @@ internal class SwapNotificationsFactory(
fun getQuotesErrorStateNotifications(
expressDataError: ExpressDataError,
fromToken: CryptoCurrency,
feeItem: FeeItemState,
includeFeeInAmount: IncludeFeeInAmount,
balanceStatus: SwapBalanceStatus,
swapFee: SwapFee?,
): ImmutableList<NotificationUM> {
return buildList {
add(getWarningForError(expressDataError, fromToken, actions.onRetryClick))
if (includeFeeInAmount is IncludeFeeInAmount.Included && feeItem is FeeItemState.Content) {
add(
NotificationUM.Warning.FeeCoverageNotification(
feeItem.amountCrypto,
feeItem.amountFiatFormatted,
),
)
if (balanceStatus is SwapBalanceStatus.FeeAdjustedAmount && swapFee != null) {
add(formatFeeCoverageNotification(swapFee))
}
}.toPersistentList()
}
@ -102,26 +104,21 @@ internal class SwapNotificationsFactory(
return updatedNotifications.toPersistentList()
}
@Suppress("LongParameterList")
fun getConfirmationStateNotifications(
quoteModel: SwapState.QuotesLoadedState,
feeCryptoCurrencyStatus: CryptoCurrencyStatus?,
selectedFeeType: FeeType,
hideFee: Boolean,
swapFee: SwapFee?,
feeError: GetFeeError?,
appRouter: AppRouter,
): ImmutableList<NotificationUM> {
val warnings = buildList {
maybeAddFeeErrorNotification(feeCryptoCurrencyStatus, quoteModel, feeError)
maybeAddRentExemptionError(quoteModel)
maybeAddDomainWarnings(quoteModel, feeCryptoCurrencyStatus, selectedFeeType)
maybeAddDomainWarnings(quoteModel, feeCryptoCurrencyStatus, swapFee)
maybeAddNeedReserveToCreateAccountWarning(quoteModel)
maybeAddPermissionNeededWarning(quoteModel)
maybeAddNetworkFeeCoverageWarning(quoteModel, selectedFeeType)
maybeAddUnableCoverFeeWarning(
quoteModel = quoteModel,
feeCryptoCurrencyStatus = feeCryptoCurrencyStatus,
hideFee = hideFee,
appRouter = appRouter,
)
maybeAddNetworkFeeCoverageWarning(quoteModel, swapFee)
maybeAddUnableCoverFeeWarning(quoteModel, feeCryptoCurrencyStatus, appRouter)
maybeAddTransactionInProgressWarning(quoteModel)
maybeAddPriceImpactNotification(quoteModel.priceImpact)
}
@ -161,35 +158,22 @@ internal class SwapNotificationsFactory(
add(notification)
}
@Suppress("LongMethod")
private fun MutableList<NotificationUM>.maybeAddDomainWarnings(
quoteModel: SwapState.QuotesLoadedState,
feeCryptoCurrencyStatus: CryptoCurrencyStatus?,
selectedFeeType: FeeType,
swapFee: SwapFee?,
) {
val swapCurrencyStatus = quoteModel.fromTokenInfo.swapCurrencyStatus
val includeFeeInAmount = quoteModel.preparedSwapConfigState.includeFeeInAmount
val balanceStatus = quoteModel.preparedSwapConfigState.balanceStatus
val amount = quoteModel.fromTokenInfo.tokenAmount
val amountToRequest = if (includeFeeInAmount is IncludeFeeInAmount.Included) {
includeFeeInAmount.amountSubtractFee
} else {
amount
}
val fee = when (val feeState = quoteModel.txFee) {
TxFeeState.Empty -> null
is TxFeeState.MultipleFeeState -> if (feeState.normalFee.feeType == selectedFeeType) {
feeState.normalFee
} else {
feeState.priorityFee
}
is TxFeeState.SingleFeeState -> feeState.fee
}
val amountToRequest = (balanceStatus as? SwapBalanceStatus.FeeAdjustedAmount)?.adjustedAmount ?: amount
val feeValue = swapFee?.fee?.amount?.value.orZero()
val isCardano = BlockchainUtils.isCardano(swapCurrencyStatus.currency.network.rawId)
// blockchain specific
addExistentialWarningNotification(
existentialDeposit = quoteModel.currencyCheck?.existentialDeposit,
feeAmount = fee?.fee?.amount?.value.orZero(),
feeAmount = feeValue,
sendingAmount = amountToRequest.value,
cryptoCurrencyStatus = swapCurrencyStatus.status,
onReduceClick = { reduceBy, reduceByDiff, _ ->
@ -212,7 +196,7 @@ internal class SwapNotificationsFactory(
if (!isCardano) {
addDustWarningNotification(
dustValue = quoteModel.currencyCheck?.dustValue,
feeValue = fee?.fee?.amount?.value.orZero(),
feeValue = feeValue,
sendingAmount = amountToRequest.value,
cryptoCurrencyStatus = swapCurrencyStatus.status,
feeCurrencyStatus = feeCryptoCurrencyStatus,
@ -235,7 +219,7 @@ internal class SwapNotificationsFactory(
sendingAmount = amountToRequest.value,
cryptoCurrencyStatus = swapCurrencyStatus.status,
feeCurrencyStatus = feeCryptoCurrencyStatus,
feeValue = fee?.feeValue.orZero(),
feeValue = feeValue,
onReduceClick = { reduceTo, _ ->
actions.onReduceToAmount(amountToRequest.copy(value = reduceTo))
},
@ -272,59 +256,59 @@ internal class SwapNotificationsFactory(
}
}
@Suppress("CanBeNonNullable")
private fun MutableList<NotificationUM>.maybeAddNetworkFeeCoverageWarning(
quoteModel: SwapState.QuotesLoadedState,
selectedFeeType: FeeType,
swapFee: SwapFee?,
) {
when (quoteModel.preparedSwapConfigState.includeFeeInAmount) {
is IncludeFeeInAmount.Included -> {
val fee = selectFeeByType(selectedFeeType, quoteModel.txFee) ?: return
when (quoteModel.preparedSwapConfigState.balanceStatus) {
is SwapBalanceStatus.FeeAdjustedAmount -> {
if (swapFee == null) return
if (needShowNetworkFeeCoverageWarningShow(quoteModel)) {
add(
NotificationUM.Warning.FeeCoverageNotification(
fee.feeCryptoFormattedWithNative,
fee.feeFiatFormattedWithNative,
),
)
add(formatFeeCoverageNotification(swapFee))
}
}
else -> Unit
}
}
private fun selectFeeByType(feeType: FeeType, txFeeState: TxFeeState): TxFee.Legacy? {
return when (txFeeState) {
TxFeeState.Empty -> null
is TxFeeState.SingleFeeState -> txFeeState.fee
is TxFeeState.MultipleFeeState -> when (feeType) {
FeeType.NORMAL -> txFeeState.normalFee
FeeType.PRIORITY -> txFeeState.priorityFee
}
private fun formatFeeCoverageNotification(swapFee: SwapFee): NotificationUM.Warning.FeeCoverageNotification {
val feeAmount = swapFee.fee.amount
val totalFeeValue = (feeAmount.value ?: BigDecimal.ZERO) + swapFee.otherNativeFee
val cryptoAmount = totalFeeValue.format {
crypto(symbol = feeAmount.currencySymbol, decimals = feeAmount.decimals)
}
val appCurrency = appCurrencyProvider()
val fiatRate = swapFee.selectedFeeToken.value.fiatRate
val fiatAmount = fiatRate?.multiply(totalFeeValue).format {
fiat(fiatCurrencyCode = appCurrency.code, fiatCurrencySymbol = appCurrency.symbol)
}
return NotificationUM.Warning.FeeCoverageNotification(
cryptoAmount = cryptoAmount,
fiatAmount = fiatAmount,
)
}
@Suppress("CyclomaticComplexMethod")
@Suppress("CyclomaticComplexMethod", "CanBeNonNullable")
private fun MutableList<NotificationUM>.maybeAddUnableCoverFeeWarning(
quoteModel: SwapState.QuotesLoadedState,
feeCryptoCurrencyStatus: CryptoCurrencyStatus?,
hideFee: Boolean,
appRouter: AppRouter,
) {
if (hideFee || feeCryptoCurrencyStatus == null) return
if (feeCryptoCurrencyStatus == null) return
val fromSwapCurrency = quoteModel.fromTokenInfo.swapCurrencyStatus
val fromCurrency = fromSwapCurrency.currency
val feeEnoughState = quoteModel.preparedSwapConfigState.feeState as? SwapFeeState.NotEnough
val shouldShowCoverWarning = !quoteModel.preparedSwapConfigState.isBalanceEnough &&
quoteModel.permissionState !is PermissionDataState.PermissionLoading &&
val balanceStatus = quoteModel.preparedSwapConfigState.balanceStatus
val insufficientFee = balanceStatus as? SwapBalanceStatus.InsufficientFee
val shouldShowCoverWarning = quoteModel.permissionState !is PermissionDataState.PermissionLoading &&
feeCryptoCurrencyStatus.currency != fromCurrency
val isCEXProvider = quoteModel.swapProvider.type == ExchangeProviderType.CEX
val isNotEnoughFee = feeEnoughState is SwapFeeState.NotEnough && !isCEXProvider ||
quoteModel.preparedSwapConfigState.includeFeeInAmount is IncludeFeeInAmount.BalanceNotEnough
val isNotEnoughFee = insufficientFee != null && !isCEXProvider
val isGaslessAvailable = isGaslessFeeSupportedForNetwork(fromCurrency.network) && isCEXProvider
if (shouldShowCoverWarning && !isGaslessAvailable || isNotEnoughFee) {
if (shouldShowCoverWarning && !isGaslessAvailable && isNotEnoughFee) {
add(
if (fromCurrency.id == feeCryptoCurrencyStatus.currency.id) {
SwapNotificationUM.Error.InsufficientFunds
@ -336,8 +320,8 @@ internal class SwapNotificationsFactory(
SwapNotificationUM.Error.UnableToCoverFeeWarning(
fromToken = fromCurrency,
feeCurrency = feeCryptoCurrencyStatus.currency,
currencyName = feeEnoughState?.currencyName ?: fromCurrency.network.name,
currencySymbol = feeEnoughState?.currencySymbol ?: fromCurrency.network.currencySymbol,
currencyName = insufficientFee.feeCurrencyName ?: fromCurrency.network.name,
currencySymbol = insufficientFee.feeCurrencySymbol ?: fromCurrency.network.currencySymbol,
onConfirmClick = if (!appRouter.stack.contains(route)) {
{ appRouter.push(route) }
} else {
@ -349,6 +333,52 @@ internal class SwapNotificationsFactory(
}
}
private fun MutableList<NotificationUM>.maybeAddFeeErrorNotification(
feeCryptoCurrencyStatus: CryptoCurrencyStatus?,
quoteModel: SwapState.QuotesLoadedState,
feeError: GetFeeError?,
) {
if (
feeError == null || feeCryptoCurrencyStatus == null ||
quoteModel.permissionState !is PermissionDataState.Empty
) {
return
}
when (feeError) {
is GetFeeError.DataError -> {
val error = feeError.cause
if (error is ExpressDataError) {
addAll(
getQuotesErrorStateNotifications(
expressDataError = error,
fromToken = quoteModel.fromTokenInfo.swapCurrencyStatus.currency,
balanceStatus = quoteModel.preparedSwapConfigState.balanceStatus,
swapFee = null,
),
)
} else {
addFeeUnreachableNotification(
tokenStatus = quoteModel.fromTokenInfo.swapCurrencyStatus.status,
coinStatus = feeCryptoCurrencyStatus,
feeError = feeError,
dustValue = quoteModel.currencyCheck?.dustValue,
onReload = actions.onRetryClick,
onClick = actions.openTokenDetailsScreen,
)
}
}
else -> addFeeUnreachableNotification(
tokenStatus = quoteModel.fromTokenInfo.swapCurrencyStatus.status,
coinStatus = feeCryptoCurrencyStatus,
feeError = feeError,
dustValue = quoteModel.currencyCheck?.dustValue,
onReload = actions.onRetryClick,
onClick = actions.openTokenDetailsScreen,
)
}
}
private fun MutableList<NotificationUM>.addReduceAmountNotification(
cryptoCurrencyStatus: CryptoCurrencyStatus,
fromAmount: SwapAmount,
@ -438,7 +468,7 @@ internal fun ExpressDataError.toExpressError(): ExpressError = when (this) {
is ExpressDataError.InvalidRequestIdError -> ExpressError.InvalidRequestIdError(code)
is ExpressDataError.InvalidPayoutAddressError -> ExpressError.InvalidPayoutAddressError(code)
is ExpressDataError.UnknownErrorWithCode -> ExpressError.InternalError(code)
ExpressDataError.UnknownError -> ExpressError.UnknownError
ExpressDataError.TooLargeSolanaTransactionError -> ExpressError.TooLargeSolanaTransactionError()
ExpressDataError.DexActiveSupplyError -> ExpressError.DexActiveSupplyError()
is ExpressDataError.UnknownError -> ExpressError.UnknownError
is ExpressDataError.TooLargeSolanaTransactionError -> ExpressError.TooLargeSolanaTransactionError()
is ExpressDataError.DexActiveSupplyError -> ExpressError.DexActiveSupplyError()
}

View file

@ -2,12 +2,9 @@ package com.tangem.feature.swap.model
import com.tangem.domain.models.currency.CryptoCurrencyStatus
import com.tangem.domain.swap.models.SwapCurrencyStatus
import com.tangem.feature.swap.domain.models.domain.SwapDataModel
import com.tangem.feature.swap.domain.models.domain.SwapPairLeast
import com.tangem.feature.swap.domain.models.domain.SwapProvider
import com.tangem.feature.swap.domain.models.ui.SwapState
import com.tangem.feature.swap.domain.models.ui.TokensDataStateExpress
import com.tangem.feature.swap.domain.models.ui.TxFee
import java.math.BigDecimal
data class SwapProcessDataState(
@ -27,9 +24,6 @@ data class SwapProcessDataState(
// Amount from input
val amount: String? = null,
val reduceBalanceBy: BigDecimal = BigDecimal.ZERO,
val swapDataModel: SwapDataModel? = null,
val selectedFee: TxFee.Legacy? = null,
val tokensDataState: TokensDataStateExpress? = null,
) {
fun getCurrentLoadedSwapState(): SwapState.QuotesLoadedState? {

View file

@ -1,9 +0,0 @@
package com.tangem.feature.swap.models
import com.tangem.domain.models.currency.CryptoCurrency
import com.tangem.feature.swap.domain.models.ui.CurrenciesGroup
data class CurrenciesGroupWithFromCurrency(
val group: CurrenciesGroup,
val fromCurrency: CryptoCurrency,
)

View file

@ -10,7 +10,6 @@ import com.tangem.core.ui.components.currency.icon.CurrencyIconState
import com.tangem.core.ui.extensions.TextReference
import com.tangem.feature.swap.domain.models.domain.SwapUIMode
import com.tangem.feature.swap.domain.models.ui.PriceImpact
import com.tangem.feature.swap.models.states.FeeItemState
import com.tangem.feature.swap.models.states.ProviderState
import kotlinx.collections.immutable.ImmutableList
import kotlinx.collections.immutable.persistentListOf
@ -23,7 +22,6 @@ internal data class SwapStateHolder(
val changeCardsButtonState: ChangeCardsButtonState,
val providerState: ProviderState,
val fee: FeeItemState = FeeItemState.Empty,
val permissionUM: SwapPermissionUM = SwapPermissionUM.Empty,
val priceImpact: PriceImpact,

View file

@ -10,6 +10,7 @@ data class SwapSuccessStateHolder(
val fee: TextReference?,
val rate: TextReference,
val shouldShowStatusButton: Boolean,
val isTransferMode: Boolean,
val providerName: TextReference,
val providerType: TextReference,
val providerIcon: String,
@ -23,4 +24,7 @@ data class SwapSuccessStateHolder(
val toTokenIconState: CurrencyIconState?,
val onExploreButtonClick: () -> Unit,
val onStatusButtonClick: () -> Unit,
)
) {
val shouldShowProvider: Boolean
get() = !isTransferMode
}

View file

@ -1,9 +1,9 @@
package com.tangem.feature.swap.models
import com.tangem.domain.models.currency.CryptoCurrency
import com.tangem.domain.express.models.ProviderFilterType
import com.tangem.feature.swap.domain.models.SwapAmount
import com.tangem.feature.swap.domain.models.domain.SwapUIMode
import com.tangem.feature.swap.domain.models.ui.TxFee
import java.math.BigDecimal
internal data class UiActions(
@ -19,11 +19,10 @@ internal data class UiActions(
val openPermissionBottomSheet: () -> Unit,
// region new actions
val onRetryClick: () -> Unit,
val onClickFee: () -> Unit,
val onSelectFeeType: (TxFee.Legacy) -> Unit,
val onProviderClick: (String) -> Unit,
val onProviderSelect: (String) -> Unit,
val onProviderFilterSelect: (ProviderFilterType) -> Unit,
val openTokenDetailsScreen: (CryptoCurrency) -> Unit,
val onSelectTokenClick: (TokenSelectionDirection) -> Unit,
val onSuccess: () -> Unit,
val onLinkClick: (String) -> Unit,

View file

@ -1,15 +0,0 @@
package com.tangem.feature.swap.models.states
import com.tangem.core.ui.components.bottomsheets.TangemBottomSheetConfigContent
import com.tangem.core.ui.extensions.TextReference
import com.tangem.feature.swap.domain.models.ui.FeeType
import kotlinx.collections.immutable.ImmutableList
data class ChooseFeeBottomSheetConfig(
val selectedFee: FeeType,
val onSelectFeeType: (FeeType) -> Unit,
val feeItems: ImmutableList<FeeItemState.Content>,
val readMoreUrl: String,
val readMore: TextReference,
val onReadMoreClick: (String) -> Unit,
) : TangemBottomSheetConfigContent

View file

@ -1,23 +0,0 @@
package com.tangem.feature.swap.models.states
import com.tangem.core.ui.extensions.TextReference
import com.tangem.feature.swap.domain.models.ui.FeeType
sealed class FeeItemState {
/**
* @param amountCrypto - crypto amount formatted with symbol
* @param amountFiatFormatted - formatted fiat amount
*/
data class Content(
val feeType: FeeType,
val title: TextReference,
val amountCrypto: String,
val symbolCrypto: String,
val amountFiatFormatted: String,
val isClickable: Boolean,
val onClick: () -> Unit,
) : FeeItemState()
object Empty : FeeItemState()
}

View file

@ -1,20 +0,0 @@
package com.tangem.feature.swap.preview
import com.tangem.core.ui.extensions.stringReference
import com.tangem.feature.swap.domain.models.ui.FeeType
import com.tangem.feature.swap.models.states.FeeItemState
object FeeItemStatePreview {
val state = FeeItemState.Content(
feeType = FeeType.NORMAL,
title = stringReference("Fee"),
amountCrypto = "1000",
symbolCrypto = "MATIC",
amountFiatFormatted = "(1000$)",
isClickable = false,
onClick = {},
)
val stateClickable = state.copy(isClickable = true)
}

View file

@ -18,6 +18,7 @@ internal data object SwapSuccessStatePreview {
providerName = TextReference.Str("1inch"),
providerType = TextReference.Str(ExchangeProviderType.DEX.providerName),
shouldShowStatusButton = false,
isTransferMode = false,
providerIcon = "",
fromTitle = AccountTitleUM.Account(
prefixText = stringReference("From"),

View file

@ -1,179 +0,0 @@
package com.tangem.feature.swap.ui
import android.content.res.Configuration
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.text.ClickableText
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.text.SpanStyle
import androidx.compose.ui.text.buildAnnotatedString
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.text.withStyle
import androidx.compose.ui.tooling.preview.Preview
import com.tangem.core.ui.components.bottomsheets.TangemBottomSheetConfig
import com.tangem.core.ui.components.bottomsheets.sheet.TangemBottomSheet
import com.tangem.core.ui.components.rows.SelectorRowItem
import com.tangem.core.ui.extensions.*
import com.tangem.core.ui.res.TangemTheme
import com.tangem.core.ui.res.TangemThemePreview
import com.tangem.feature.swap.domain.models.ui.FeeType
import com.tangem.feature.swap.models.states.ChooseFeeBottomSheetConfig
import com.tangem.feature.swap.models.states.FeeItemState
import com.tangem.feature.swap.presentation.R
import kotlinx.collections.immutable.toImmutableList
@Composable
fun ChooseFeeBottomSheet(config: TangemBottomSheetConfig) {
TangemBottomSheet(
config = config,
containerColor = TangemTheme.colors.background.tertiary,
titleText = resourceReference(R.string.common_fee_selector_title),
) { content: ChooseFeeBottomSheetConfig ->
ChooseFeeBottomSheetContent(content = content)
}
}
@Composable
private fun ChooseFeeBottomSheetContent(content: ChooseFeeBottomSheetConfig) {
Column(
modifier = Modifier
.background(TangemTheme.colors.background.tertiary)
.padding(bottom = TangemTheme.dimens.spacing8),
) {
Column(
modifier = Modifier
.padding(TangemTheme.dimens.spacing16)
.clip(TangemTheme.shapes.roundedCornersXMedium)
.background(
color = TangemTheme.colors.background.action,
shape = TangemTheme.shapes.roundedCornersXMedium,
),
) {
FeeItemsBlock(content)
}
FooterBlock(
readMore = content.readMore,
onReadMoreClick = { content.onReadMoreClick(content.readMoreUrl) },
)
}
}
@Composable
private fun FooterBlock(readMore: TextReference, onReadMoreClick: () -> Unit) {
val linkText = readMore.resolveReference()
val fullString = stringResourceSafe(R.string.common_fee_selector_footer, linkText)
val linkTextPosition = fullString.length - linkText.length
val annotatedString = buildAnnotatedString {
withStyle(SpanStyle(color = TangemTheme.colors.text.tertiary)) {
append(fullString.substring(0, linkTextPosition))
}
withStyle(SpanStyle(color = TangemTheme.colors.text.accent)) {
append(fullString.substring(linkTextPosition, fullString.length))
}
}
val click = { i: Int ->
val readMoreStyle = requireNotNull(annotatedString.spanStyles.getOrNull(1))
if (i in readMoreStyle.start..readMoreStyle.end) {
onReadMoreClick()
}
}
ClickableText(
text = annotatedString,
modifier = Modifier
.padding(
vertical = TangemTheme.dimens.spacing8,
horizontal = TangemTheme.dimens.spacing16,
),
style = TangemTheme.typography.caption2.copy(textAlign = TextAlign.Start),
onClick = click,
)
}
@Composable
private fun FeeItemsBlock(content: ChooseFeeBottomSheetConfig) {
content.feeItems.forEachIndexed { index, feeItem ->
val isSelected = feeItem.feeType == content.selectedFee
val shouldShowDivider = content.feeItems.lastIndex != index
val symbol = "${feeItem.symbolCrypto}"
val preDotText = "${feeItem.amountCrypto}$symbol"
val postDot = feeItem.amountFiatFormatted
val ellipsizeOffset = symbol.length
when (feeItem.feeType) {
FeeType.NORMAL -> {
SelectorRowItem(
title = resourceReference(R.string.common_fee_selector_option_market),
iconRes = R.drawable.ic_bird_24,
preDot = TextReference.Str(preDotText),
postDot = TextReference.Str(postDot),
ellipsizeOffset = ellipsizeOffset,
isSelected = isSelected,
onSelect = { content.onSelectFeeType(feeItem.feeType) },
showDivider = shouldShowDivider,
)
}
FeeType.PRIORITY -> {
SelectorRowItem(
title = resourceReference(R.string.common_fee_selector_option_fast),
iconRes = R.drawable.ic_hare_24,
preDot = TextReference.Str(preDotText),
postDot = TextReference.Str(postDot),
ellipsizeOffset = ellipsizeOffset,
isSelected = isSelected,
onSelect = { content.onSelectFeeType(feeItem.feeType) },
showDivider = shouldShowDivider,
)
}
}
}
}
// region Preview
@Composable
@Preview(showBackground = true)
@Preview(showBackground = true, uiMode = Configuration.UI_MODE_NIGHT_YES)
private fun Preview_ChooseFeeBottomSheet() {
val feeItems = listOf(
FeeItemState.Content(
feeType = FeeType.NORMAL,
title = stringReference("Fee"),
amountCrypto = "1000",
symbolCrypto = "MATIC",
amountFiatFormatted = "(10$)",
isClickable = false,
onClick = {},
),
FeeItemState.Content(
feeType = FeeType.PRIORITY,
title = stringReference("Fee"),
amountCrypto = "2000",
symbolCrypto = "MATIC",
amountFiatFormatted = "(10$)",
isClickable = false,
onClick = {},
),
).toImmutableList()
val content = ChooseFeeBottomSheetConfig(
selectedFee = FeeType.NORMAL,
onSelectFeeType = {},
feeItems = feeItems,
readMore = stringReference("Read more"),
readMoreUrl = "",
onReadMoreClick = {},
)
TangemThemePreview {
ChooseFeeBottomSheet(
config = TangemBottomSheetConfig(
isShown = true,
onDismissRequest = {},
content = content,
),
)
}
}
// endregion Preview

View file

@ -1,62 +0,0 @@
package com.tangem.feature.swap.ui
import android.content.res.Configuration
import androidx.compose.foundation.background
import androidx.compose.foundation.clickable
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.tooling.preview.PreviewParameter
import androidx.compose.ui.tooling.preview.PreviewParameterProvider
import com.tangem.core.ui.components.inputrow.InputRowDefault
import com.tangem.core.ui.extensions.stringReference
import com.tangem.core.ui.res.TangemTheme
import com.tangem.core.ui.res.TangemThemePreview
import com.tangem.feature.swap.models.states.FeeItemState
import com.tangem.feature.swap.presentation.R
import com.tangem.feature.swap.preview.FeeItemStatePreview
@Composable
fun FeeItemBlock(state: FeeItemState) {
if (state is FeeItemState.Content) {
FeeItem(state = state)
}
}
@Composable
fun FeeItem(state: FeeItemState.Content) {
val description = "${state.amountCrypto}${state.symbolCrypto} (${state.amountFiatFormatted})"
val icon = R.drawable.ic_chevron_right_24.takeIf { state.isClickable }
InputRowDefault(
title = state.title,
text = stringReference(description),
iconRes = icon,
modifier = Modifier
.clip(shape = TangemTheme.shapes.roundedCornersXMedium)
.background(color = TangemTheme.colors.background.action)
.clickable(
enabled = state.isClickable,
onClick = state.onClick,
),
)
}
// region Preview
@Preview(showBackground = true, widthDp = 360)
@Preview(showBackground = true, widthDp = 360, uiMode = Configuration.UI_MODE_NIGHT_YES)
@Composable
private fun FeeItem_Preview(@PreviewParameter(FeeItemPreviewProvider::class) data: FeeItemState.Content) {
TangemThemePreview {
FeeItem(data)
}
}
private class FeeItemPreviewProvider : PreviewParameterProvider<FeeItemState.Content> {
override val values: Sequence<FeeItemState.Content>
get() = sequenceOf(
FeeItemStatePreview.state,
FeeItemStatePreview.state.copy(isClickable = true),
)
}
// endregion

View file

@ -27,6 +27,7 @@ import com.tangem.domain.models.currency.CryptoCurrency
import com.tangem.domain.models.currency.CryptoCurrencyStatus
import com.tangem.domain.models.wallet.isHotWallet
import com.tangem.domain.swap.models.SwapCurrencyStatus
import com.tangem.domain.transaction.error.GetFeeError
import com.tangem.domain.transaction.usecase.gasless.IsGaslessFeeSupportedForNetwork
import com.tangem.feature.swap.converters.SwapProviderStateBuilder
import com.tangem.feature.swap.domain.models.ExpressDataError
@ -40,6 +41,7 @@ import com.tangem.feature.swap.models.SwapButton.Mode
import com.tangem.feature.swap.models.states.*
import com.tangem.feature.swap.presentation.R
import com.tangem.feature.swap.utils.formatToUIRepresentation
import com.tangem.features.send.v2.api.entity.FeeSelectorUM
import com.tangem.features.swap.SwapFeatureToggles
import com.tangem.utils.Provider
import com.tangem.utils.StringsSigns
@ -67,7 +69,11 @@ internal class StateBuilder(
private val iconStateConverter by lazy(::CryptoCurrencyToIconStateConverter)
private val notificationsFactory by lazy(LazyThreadSafetyMode.NONE) {
SwapNotificationsFactory(actions, isGaslessFeeSupportedForNetwork)
SwapNotificationsFactory(
actions = actions,
isGaslessFeeSupportedForNetwork = isGaslessFeeSupportedForNetwork,
appCurrencyProvider = appCurrencyProvider,
)
}
fun createInitialLoadingState(swapUIMode: SwapUIMode = SwapUIMode.Detailed): SwapStateHolder {
@ -80,7 +86,6 @@ internal class StateBuilder(
isFromCard = false,
emptyAmountState = SwapState.EmptyAmountState(TextReference.EMPTY),
),
fee = FeeItemState.Empty,
swapButton = SwapButton(
walletInteractionIcon = null,
isEnabled = false,
@ -127,7 +132,6 @@ internal class StateBuilder(
),
notifications = persistentListOf(),
isInsufficientFunds = false,
fee = FeeItemState.Empty,
swapButton = SwapButton(
walletInteractionIcon = fromSwapCurrencyStatus?.userWallet?.let(::walletInterationIcon),
isEnabled = false,
@ -158,7 +162,6 @@ internal class StateBuilder(
onRetryClick = onRetry,
),
permissionUM = SwapPermissionUM.Empty,
fee = FeeItemState.Empty,
swapButton = fromSwapCurrencyStatus?.let {
SwapButton(
walletInteractionIcon = walletInterationIcon(fromSwapCurrencyStatus.userWallet),
@ -199,7 +202,6 @@ internal class StateBuilder(
),
),
notifications = persistentListOf(),
fee = FeeItemState.Empty,
swapButton = SwapButton(
walletInteractionIcon = walletInterationIcon(fromSwapCurrencyStatus.userWallet),
isEnabled = false,
@ -237,7 +239,6 @@ internal class StateBuilder(
),
notifications = persistentListOf(),
isInsufficientFunds = false,
fee = FeeItemState.Empty,
swapButton = SwapButton(
walletInteractionIcon = fromSwapCurrencyStatus?.userWallet?.let(::walletInterationIcon),
isEnabled = false,
@ -390,7 +391,6 @@ internal class StateBuilder(
isBalanceHidden = isBalanceHiddenProvider(),
),
notifications = notificationsFactory.getSwapNotSupportedNotifications(),
fee = FeeItemState.Empty,
swapButton = SwapButton(
walletInteractionIcon = walletInterationIcon(fromSwapCurrencyStatus.userWallet),
isEnabled = false,
@ -431,7 +431,6 @@ internal class StateBuilder(
amountEquivalent = null,
),
notifications = persistentListOf(),
fee = FeeItemState.Empty,
swapButton = SwapButton(
walletInteractionIcon = walletInterationIcon(fromSwapCurrencyStatus.userWallet),
isEnabled = false,
@ -454,13 +453,12 @@ internal class StateBuilder(
swapProvider: SwapProvider,
bestRatedProviderId: String,
isNeedBestRateBadge: Boolean,
selectedFeeType: FeeType,
needApplyFCARestrictions: Boolean,
hideFee: Boolean,
swapFee: SwapFee?,
feeError: FeeSelectorUM.Error?,
): SwapStateHolder {
if (uiStateHolder.sendCardData !is SwapCardState.SwapCardData) return uiStateHolder
if (uiStateHolder.receiveCardData !is SwapCardState.SwapCardData) return uiStateHolder
val feeState = if (hideFee) FeeItemState.Empty else createFeeState(quoteModel.txFee, selectedFeeType)
val fromSwapCurrencyStatus = quoteModel.fromTokenInfo.swapCurrencyStatus
val toSwapCurrencyStatus = quoteModel.toTokenInfo.swapCurrencyStatus
val isInsufficientFunds = isInsufficientFundsCondition(quoteModel)
@ -468,8 +466,8 @@ internal class StateBuilder(
val notifications = notificationsFactory.getConfirmationStateNotifications(
quoteModel = quoteModel,
feeCryptoCurrencyStatus = feeCryptoCurrencyStatus,
selectedFeeType = selectedFeeType,
hideFee = hideFee,
swapFee = swapFee,
feeError = feeError?.error,
appRouter = appRouter,
)
@ -547,10 +545,9 @@ internal class StateBuilder(
permissionUM = convertPermissionState(
permissionDataState = quoteModel.permissionState,
),
fee = feeState,
swapButton = SwapButton(
walletInteractionIcon = walletInterationIcon(fromSwapCurrencyStatus.userWallet),
isEnabled = getSwapButtonEnabled(notifications, priceImpact),
isEnabled = getSwapButtonEnabled(notifications, priceImpact, swapFee),
isHoldToConfirm = fromSwapCurrencyStatus.userWallet.isHotWallet,
onClick = actions.onSwapClick,
),
@ -572,6 +569,34 @@ internal class StateBuilder(
)
}
fun createFeeErrorState(
uiStateHolder: SwapStateHolder,
quoteModel: SwapState.QuotesLoadedState,
feeCryptoCurrencyStatus: CryptoCurrencyStatus?,
feeError: GetFeeError,
): SwapStateHolder {
val fromSwapCurrencyStatus = quoteModel.fromTokenInfo.swapCurrencyStatus
if (feeCryptoCurrencyStatus == null) return uiStateHolder
val notifications = notificationsFactory.getConfirmationStateNotifications(
quoteModel = quoteModel,
feeCryptoCurrencyStatus = feeCryptoCurrencyStatus,
swapFee = null,
feeError = feeError,
appRouter = appRouter,
)
return uiStateHolder.copy(
notifications = notifications,
swapButton = SwapButton(
walletInteractionIcon = walletInterationIcon(fromSwapCurrencyStatus.userWallet),
isEnabled = false,
isHoldToConfirm = fromSwapCurrencyStatus.userWallet.isHotWallet,
onClick = actions.onSwapClick,
),
)
}
private fun shouldShowMaxAmount(fromToken: CryptoCurrency?, toCurrency: CryptoCurrency?): Boolean {
return !(fromToken is CryptoCurrency.Coin && fromToken.network.id == toCurrency?.network?.id)
}
@ -596,12 +621,15 @@ internal class StateBuilder(
}
private fun isInsufficientFundsCondition(quoteModel: SwapState.QuotesLoadedState): Boolean {
return !quoteModel.preparedSwapConfigState.isBalanceEnough &&
quoteModel.preparedSwapConfigState.includeFeeInAmount !is IncludeFeeInAmount.Included
return quoteModel.preparedSwapConfigState.balanceStatus is SwapBalanceStatus.InsufficientAmount
}
private fun getSwapButtonEnabled(notifications: ImmutableList<NotificationUM>, priceImpact: PriceImpact): Boolean {
return notifications.none { notification ->
private fun getSwapButtonEnabled(
notifications: ImmutableList<NotificationUM>,
priceImpact: PriceImpact,
swapFee: SwapFee?,
): Boolean {
return swapFee != null && notifications.none { notification ->
notification is SwapNotificationUM.Error || notification is NotificationUM.Error ||
notification is SwapNotificationUM.Warning.ExpressErrorWarning ||
notification is SwapNotificationUM.Warning.ExpressGeneralError ||
@ -618,9 +646,10 @@ internal class StateBuilder(
swapProvider: SwapProvider,
fromToken: TokenSwapInfo,
toSwapCurrencyStatus: SwapCurrencyStatus?,
includeFeeInAmount: IncludeFeeInAmount,
balanceStatus: SwapBalanceStatus,
expressDataError: ExpressDataError,
needApplyFCARestrictions: Boolean,
swapFee: SwapFee?,
): SwapStateHolder {
if (uiStateHolder.sendCardData !is SwapCardState.SwapCardData) return uiStateHolder
if (uiStateHolder.receiveCardData !is SwapCardState.SwapCardData) return uiStateHolder
@ -629,8 +658,8 @@ internal class StateBuilder(
val notifications = notificationsFactory.getQuotesErrorStateNotifications(
expressDataError = expressDataError,
fromToken = fromSwapCurrencyStatus.currency,
feeItem = uiStateHolder.fee,
includeFeeInAmount = includeFeeInAmount,
balanceStatus = balanceStatus,
swapFee = swapFee,
)
val providerState = getProviderStateForError(
@ -668,7 +697,6 @@ internal class StateBuilder(
receiveCardData = receiveCardData,
notifications = notifications,
permissionUM = SwapPermissionUM.Empty,
fee = FeeItemState.Empty,
swapButton = SwapButton(
walletInteractionIcon = walletInterationIcon(fromSwapCurrencyStatus.userWallet),
isEnabled = false,
@ -740,7 +768,6 @@ internal class StateBuilder(
),
notifications = persistentListOf(),
isInsufficientFunds = false,
fee = FeeItemState.Empty,
swapButton = SwapButton(
walletInteractionIcon = fromSwapCurrencyStatus?.userWallet?.let(::walletInterationIcon),
isEnabled = false,
@ -853,38 +880,6 @@ internal class StateBuilder(
)
}
private fun createFeeState(txFeeState: TxFeeState, feeType: FeeType): FeeItemState {
val isClickable: Boolean
val fee = when (txFeeState) {
TxFeeState.Empty -> return FeeItemState.Empty
is TxFeeState.SingleFeeState -> {
isClickable = false
txFeeState.fee
}
is TxFeeState.MultipleFeeState -> {
isClickable = true
when (feeType) {
FeeType.NORMAL -> {
txFeeState.normalFee
}
FeeType.PRIORITY -> {
txFeeState.priorityFee
}
}
}
}
return FeeItemState.Content(
feeType = feeType,
title = resourceReference(R.string.common_network_fee_title),
amountCrypto = fee.feeCryptoFormattedWithNative, // display fee with native as workaround for okx
symbolCrypto = fee.cryptoSymbol,
amountFiatFormatted = fee.feeFiatFormattedWithNative, // display fee with native as workaround for okx
isClickable = isClickable,
onClick = actions.onClickFee,
)
}
fun loadingPermissionState(uiState: SwapStateHolder): SwapStateHolder {
return uiState.copy(
swapButton = uiState.swapButton.copy(
@ -903,6 +898,7 @@ internal class StateBuilder(
onExploreClick: () -> Unit,
onStatusClick: () -> Unit,
txUrl: String,
swapFee: SwapFee?,
): SwapStateHolder {
val fromSwapCurrencyStatus = requireNotNull(dataState.fromSwapCurrencyStatus)
val toSwapCurrencyStatus = requireNotNull(dataState.toSwapCurrencyStatus)
@ -922,11 +918,10 @@ internal class StateBuilder(
providerName = stringReference(providerState.name),
providerType = stringReference(providerState.type),
shouldShowStatusButton = shouldShowStatus,
isTransferMode = false,
providerIcon = providerState.iconUrl,
rate = providerState.subtitle,
fee = dataState.selectedFee?.let { fee ->
stringReference("${fee.feeCryptoFormattedWithNative} (${fee.feeFiatFormattedWithNative})")
},
fee = swapFee?.let { fee -> formatSwapFeeForSuccess(fee) },
fromTitle = getCardAccountTitle(fromSwapCurrencyStatus.account, isFromCard = true),
toTitle = getCardAccountTitle(toSwapCurrencyStatus.account, isFromCard = false),
fromTokenAmount = stringReference(swapTransactionState.fromAmount.orEmpty()),
@ -967,6 +962,7 @@ internal class StateBuilder(
providerName = stringReference(providerState.name),
providerType = stringReference(providerState.type),
shouldShowStatusButton = false,
isTransferMode = false,
providerIcon = providerState.iconUrl,
rate = providerState.subtitle,
fee = TextReference.EMPTY,
@ -1111,57 +1107,18 @@ internal class StateBuilder(
)
}
fun showSelectFeeBottomSheet(
uiState: SwapStateHolder,
selectedFee: FeeType,
txFeeState: TxFeeState.MultipleFeeState,
readMoreUrl: String,
onDismiss: () -> Unit,
): SwapStateHolder {
val config = ChooseFeeBottomSheetConfig(
selectedFee = selectedFee,
onSelectFeeType = { feeType ->
val selectedItem = when (feeType) {
FeeType.NORMAL -> txFeeState.normalFee
FeeType.PRIORITY -> txFeeState.priorityFee
}
actions.onSelectFeeType.invoke(selectedItem)
},
readMoreUrl = readMoreUrl,
feeItems = txFeeState.toFeeItemState(),
readMore = resourceReference(R.string.common_read_more),
onReadMoreClick = actions.onLinkClick,
)
return uiState.copy(
bottomSheetConfig = TangemBottomSheetConfig(
isShown = true,
onDismissRequest = onDismiss,
content = config,
),
)
}
private fun TxFeeState.MultipleFeeState.toFeeItemState(): ImmutableList<FeeItemState.Content> {
return listOf(
FeeItemState.Content(
feeType = this.normalFee.feeType,
title = resourceReference(R.string.common_network_fee_title),
amountCrypto = this.normalFee.feeCryptoFormattedWithNative,
symbolCrypto = this.normalFee.cryptoSymbol,
amountFiatFormatted = this.normalFee.feeFiatFormattedWithNative,
isClickable = true,
onClick = {},
),
FeeItemState.Content(
feeType = this.priorityFee.feeType,
title = resourceReference(R.string.common_network_fee_title),
amountCrypto = this.priorityFee.feeCryptoFormattedWithNative,
symbolCrypto = this.priorityFee.cryptoSymbol,
amountFiatFormatted = this.priorityFee.feeFiatFormattedWithNative,
isClickable = true,
onClick = {},
),
).toImmutableList()
private fun formatSwapFeeForSuccess(swapFee: SwapFee): TextReference {
val feeAmount = swapFee.fee.amount
val totalFeeValue = (feeAmount.value ?: BigDecimal.ZERO) + swapFee.otherNativeFee
val cryptoFormatted = totalFeeValue.format {
crypto(symbol = feeAmount.currencySymbol, decimals = feeAmount.decimals)
}
val appCurrency = appCurrencyProvider()
val fiatRate = swapFee.selectedFeeToken.value.fiatRate
val fiatFormatted = fiatRate?.multiply(totalFeeValue).format {
fiat(fiatCurrencyCode = appCurrency.code, fiatCurrencySymbol = appCurrency.symbol)
}
return stringReference("$cryptoFormatted ($fiatFormatted)")
}
private fun Map.Entry<SwapProvider, SwapState>.convertToProviderBottomSheetState(

View file

@ -36,7 +36,6 @@ import com.tangem.core.ui.utils.WindowInsetsZero
import com.tangem.feature.swap.component.SwapFeeSelectorBlockComponent
import com.tangem.feature.swap.domain.models.domain.SwapUIMode
import com.tangem.feature.swap.models.SwapStateHolder
import com.tangem.feature.swap.models.states.ChooseFeeBottomSheetConfig
import com.tangem.feature.swap.models.states.ChooseProviderBottomSheetConfig
import com.tangem.feature.swap.presentation.R
@ -74,7 +73,6 @@ internal fun SwapScreen(stateHolder: SwapStateHolder, feeSelectorBlockComponent:
when (config.content) {
is ChooseProviderBottomSheetConfig -> ChooseProviderBottomSheet(config = config)
is ChooseFeeBottomSheetConfig -> ChooseFeeBottomSheet(config = config)
}
}
}

View file

@ -40,10 +40,8 @@ import com.tangem.core.ui.res.TangemTheme
import com.tangem.core.ui.res.TangemThemePreview
import com.tangem.core.ui.test.SwapTokenScreenTestTags
import com.tangem.feature.swap.domain.models.domain.SwapUIMode
import com.tangem.feature.swap.domain.models.ui.FeeType
import com.tangem.feature.swap.domain.models.ui.PriceImpact
import com.tangem.feature.swap.models.*
import com.tangem.feature.swap.models.states.FeeItemState
import com.tangem.feature.swap.models.states.ProviderState
import com.tangem.feature.swap.models.states.SwapNotificationUM
import com.tangem.feature.swap.presentation.R
@ -86,11 +84,7 @@ internal fun SwapScreenContent(
ProviderItemBlock(state = state.providerState)
}
if (feeBlock != null) {
feeBlock(Modifier.fillMaxWidth())
} else {
FeeItemBlock(state = state.fee)
}
feeBlock?.invoke(Modifier.fillMaxWidth())
if (state.notifications.isNotEmpty()) SwapNotifications(notifications = state.notifications)
@ -399,15 +393,6 @@ private fun getButtonTitle(mode: SwapButton.Mode): String {
private val state = SwapStateHolder(
sendCardData = sendCard,
receiveCardData = receiveCard,
fee = FeeItemState.Content(
feeType = FeeType.NORMAL,
title = stringReference("Fee"),
amountCrypto = "100",
symbolCrypto = "1000",
amountFiatFormatted = "(100)",
isClickable = true,
onClick = {},
),
notifications = persistentListOf(
SwapNotificationUM.Info.PermissionNeeded(
onApproveClick = {},

View file

@ -73,7 +73,11 @@ private fun SwapSuccessScreenContent(
.padding(horizontal = TangemTheme.dimens.spacing16),
) {
TransactionDoneTitle(
title = resourceReference(R.string.swap_in_progress),
title = if (state.isTransferMode) {
resourceReference(R.string.transfer_in_progress_title)
} else {
resourceReference(R.string.swap_in_progress)
},
subtitle = resourceReference(
R.string.send_date_format,
wrappedList(
@ -97,16 +101,18 @@ private fun SwapSuccessScreenContent(
tokenIconState = state.toTokenIconState,
)
SpacerH16()
InputRowBestRate(
imageUrl = state.providerIcon,
title = state.providerName,
titleExtra = state.providerType,
subtitle = state.rate,
modifier = Modifier
.clip(TangemTheme.shapes.roundedCornersXMedium)
.background(TangemTheme.colors.background.action),
)
SpacerH16()
if (state.shouldShowProvider) {
InputRowBestRate(
imageUrl = state.providerIcon,
title = state.providerName,
titleExtra = state.providerType,
subtitle = state.rate,
modifier = Modifier
.clip(TangemTheme.shapes.roundedCornersXMedium)
.background(TangemTheme.colors.background.action),
)
SpacerH16()
}
if (feeSelectorUM != null) {
FeeBlockSuccess(feeSelectorUM)

View file

@ -14,13 +14,16 @@ import com.tangem.core.ui.extensions.stringReference
import com.tangem.core.ui.format.bigdecimal.crypto
import com.tangem.core.ui.format.bigdecimal.fiat
import com.tangem.core.ui.format.bigdecimal.format
import com.tangem.core.ui.utils.parseBigDecimalOrNull
import com.tangem.domain.appcurrency.model.AppCurrency
import com.tangem.domain.models.account.Account
import com.tangem.domain.models.currency.CryptoCurrencyStatus
import com.tangem.domain.swap.models.SwapCurrencyStatus
import com.tangem.feature.swap.domain.models.ui.SwapState
import com.tangem.feature.swap.domain.models.ui.TokenSwapInfo
import com.tangem.feature.swap.model.SwapProcessDataState
import com.tangem.feature.swap.models.*
import com.tangem.feature.swap.models.SwapButton.Mode
import com.tangem.feature.swap.presentation.R
import com.tangem.feature.swap.utils.formatToUIRepresentation
import com.tangem.utils.StringsSigns.DASH_SIGN
@ -61,10 +64,11 @@ internal class SwapTransferStateBuilder @Inject constructor() {
isInsufficientFunds = isInsufficientBalance,
swapButton = SwapButton(
walletInteractionIcon = walletInterationIcon(transferState.userWallet),
isEnabled = !isInsufficientBalance,
mode = SwapButton.Mode.TRANSFER,
isEnabled = false,
mode = Mode.TRANSFER,
onClick = actions.onTransferClick,
),
changeCardsButtonState = ChangeCardsButtonState.ENABLED,
)
}
@ -185,4 +189,73 @@ internal class SwapTransferStateBuilder @Inject constructor() {
is Account.Payment -> AccountIconUM.Payment
}
}
fun createTransferInProgressState(uiState: SwapStateHolder): SwapStateHolder {
return uiState.copy(
swapButton = uiState.swapButton.copy(
isEnabled = false,
mode = Mode.TRANSFER_PROGRESSING,
),
)
}
@Suppress("LongParameterList")
fun createSuccessState(
uiState: SwapStateHolder,
dataState: SwapProcessDataState,
appCurrency: AppCurrency,
isAccountsMode: Boolean,
txUrl: String,
timestamp: Long,
fee: TextReference?,
): SwapStateHolder {
val fromSwapCurrencyStatus = requireNotNull(dataState.fromSwapCurrencyStatus)
val toSwapCurrencyStatus = requireNotNull(dataState.toSwapCurrencyStatus)
val amount = dataState.amount?.parseBigDecimalOrNull() ?: BigDecimal.ZERO
val fromCurrency = fromSwapCurrencyStatus.currency
val toCurrency = toSwapCurrencyStatus.currency
val fromAmountText = amount.format { crypto(fromCurrency.symbol, fromCurrency.decimals) }
val toAmountText = amount.format { crypto(toCurrency.symbol, toCurrency.decimals) }
val fromFiatAmount = getFormattedFiatAmount(
appCurrency = appCurrency,
amount = fromSwapCurrencyStatus.status.value.fiatRate?.multiply(amount),
)
val toFiatAmount = getFormattedFiatAmount(
appCurrency = appCurrency,
amount = toSwapCurrencyStatus.status.value.fiatRate?.multiply(amount),
)
return uiState.copy(
successState = SwapSuccessStateHolder(
timestamp = timestamp,
txUrl = txUrl,
providerName = TextReference.EMPTY,
providerType = TextReference.EMPTY,
shouldShowStatusButton = false,
isTransferMode = true,
providerIcon = "",
rate = TextReference.EMPTY,
fee = fee,
fromTitle = getCardAccountTitle(
account = fromSwapCurrencyStatus.account,
isAccountsMode = isAccountsMode,
isFromCard = true,
),
toTitle = getCardAccountTitle(
account = toSwapCurrencyStatus.account,
isAccountsMode = isAccountsMode,
isFromCard = false,
),
fromTokenAmount = stringReference(fromAmountText),
toTokenAmount = stringReference(toAmountText),
fromTokenFiatAmount = fromFiatAmount,
toTokenFiatAmount = toFiatAmount,
fromTokenIconState = iconConverter.convert(fromSwapCurrencyStatus.status),
toTokenIconState = iconConverter.convert(toSwapCurrencyStatus.status),
onExploreButtonClick = {},
onStatusButtonClick = {},
),
)
}
}

View file

@ -1075,7 +1075,14 @@ internal class DefaultInitialCurrenciesResolverTest {
)
setupSupplier(listOf(account1, account2))
setupAvailability(linkedMapOf(initialInAccount to true, lowBalance to true, midBalance to true, highBalance to true))
setupAvailability(
linkedMapOf(
initialInAccount to true,
lowBalance to true,
midBalance to true,
highBalance to true
)
)
setupAvailability(linkedMapOf(outsiderCurrency to true))
val (from, to) = resolver.invoke(

View file

@ -14,7 +14,6 @@ import com.tangem.domain.transaction.usecase.gasless.IsGaslessFeeSupportedForNet
import com.tangem.feature.swap.domain.models.ui.PriceImpact
import com.tangem.feature.swap.domain.models.ui.SwapState
import com.tangem.feature.swap.models.*
import com.tangem.feature.swap.models.states.FeeItemState
import com.tangem.feature.swap.models.states.ProviderState
import com.tangem.feature.swap.models.states.SwapNotificationUM
import com.tangem.feature.swap.ui.StateBuilder
@ -79,13 +78,6 @@ internal class StateBuilderInitialStateTest {
assertThat(result.receiveCardData).isInstanceOf(SwapCardState.Empty::class.java)
}
@Test
fun `should return loading state with Empty fee`() {
val result = sut.createInitialLoadingState()
assertThat(result.fee).isInstanceOf(FeeItemState.Empty::class.java)
}
@Test
fun `should return loading state with DISABLED changeCardsButtonState`() {
val result = sut.createInitialLoadingState()
@ -402,19 +394,6 @@ internal class StateBuilderInitialStateTest {
assertThat(result.permissionUM).isEqualTo(SwapPermissionUM.Empty)
}
@Test
fun `WHEN called THEN fee is Empty`() {
val baseState = buildBaseStateWithSwapCardData(coldWallet)
val result = sut.createInitialErrorState(
fromSwapCurrencyStatus = null,
uiStateHolder = baseState,
expressError = expressError,
onRetry = {},
)
assertThat(result.fee).isInstanceOf(FeeItemState.Empty::class.java)
}
@Test
fun `WHEN called THEN changeCardsButtonState is ENABLED`() {

View file

@ -9,7 +9,6 @@ import com.tangem.domain.models.wallet.UserWalletId
import com.tangem.domain.transaction.usecase.gasless.IsGaslessFeeSupportedForNetwork
import com.tangem.feature.swap.domain.models.ui.SwapState
import com.tangem.feature.swap.models.*
import com.tangem.feature.swap.models.states.FeeItemState
import com.tangem.feature.swap.models.states.ProviderState
import com.tangem.feature.swap.models.states.SwapNotificationUM
import com.tangem.feature.swap.ui.StateBuilder
@ -128,21 +127,6 @@ internal class StateBuilderPairsTest {
assertThat(result.notifications[0]).isInstanceOf(SwapNotificationUM.Warning.SwapNotSupported::class.java)
}
@Test
fun `GIVEN valid state WHEN called THEN fee is Empty`() {
val baseState = buildReadyState(coldWallet)
val fromStatus = buildSwapCurrencyStatus(coldWallet)
val toStatus = buildSwapCurrencyStatus(coldWallet)
val result = sut.createSwapNotSupportedState(
uiStateHolder = baseState,
fromSwapCurrencyStatus = fromStatus,
toSwapCurrencyStatus = toStatus,
)
assertThat(result.fee).isInstanceOf(FeeItemState.Empty::class.java)
}
@Test
fun `GIVEN valid state WHEN called THEN providerState is Empty`() {
val baseState = buildReadyState(coldWallet)
@ -274,20 +258,6 @@ internal class StateBuilderPairsTest {
assertThat(result.isInsufficientFunds).isFalse()
}
@Test
fun `WHEN called THEN fee is Empty`() {
val baseState = buildReadyState(coldWallet)
val result = sut.updateCurrenciesState(
uiStateHolder = baseState,
emptyAmountState = emptyAmountState,
fromSwapCurrencyStatus = null,
toSwapCurrencyStatus = null,
shouldResetAmount = false,
)
assertThat(result.fee).isInstanceOf(FeeItemState.Empty::class.java)
}
@Test
fun `WHEN called THEN changeCardsButtonState is ENABLED`() {

View file

@ -1,847 +0,0 @@
package com.tangem.feature.swap
import com.google.common.truth.Truth.assertThat
import com.tangem.common.routing.AppRouter
import com.tangem.domain.appcurrency.model.AppCurrency
import com.tangem.domain.models.wallet.UserWallet
import com.tangem.domain.models.wallet.UserWalletId
import com.tangem.domain.swap.models.SwapCurrencyStatus
import com.tangem.domain.transaction.usecase.gasless.IsGaslessFeeSupportedForNetwork
import com.tangem.feature.swap.domain.models.ExpressDataError
import com.tangem.feature.swap.domain.models.SwapAmount
import com.tangem.feature.swap.domain.models.domain.*
import com.tangem.feature.swap.domain.models.ui.*
import com.tangem.feature.swap.models.*
import com.tangem.feature.swap.models.states.FeeItemState
import com.tangem.feature.swap.models.states.ProviderState
import com.tangem.feature.swap.ui.StateBuilder
import com.tangem.features.swap.SwapFeatureToggles
import com.tangem.utils.Provider
import io.mockk.every
import io.mockk.mockk
import org.junit.jupiter.api.BeforeEach
import org.junit.jupiter.api.Nested
import org.junit.jupiter.api.Test
import java.math.BigDecimal
internal class StateBuilderQuotesTest {
private val actions: UiActions = mockk(relaxed = true)
private val isBalanceHiddenProvider: Provider<Boolean> = mockk()
private val appCurrencyProvider: Provider<AppCurrency> = mockk()
private val isAccountsModeProvider: Provider<Boolean> = mockk()
private val isGaslessFeeSupportedForNetwork: IsGaslessFeeSupportedForNetwork = mockk()
private val swapFeatureToggles: SwapFeatureToggles = mockk(relaxed = true)
private val appRouter: AppRouter = mockk()
private lateinit var sut: StateBuilder
private val userWalletId = UserWalletId("aabbccdd")
private val coldWallet: UserWallet.Cold = mockk(relaxed = true) {
every { walletId } returns userWalletId
}
private val hotWallet: UserWallet.Hot = mockk(relaxed = true) {
every { walletId } returns userWalletId
}
private val emptyAmountState = SwapState.EmptyAmountState(
zeroAmountEquivalent = com.tangem.core.ui.extensions.stringReference("$0.00"),
)
@BeforeEach
fun setup() {
every { isBalanceHiddenProvider() } returns false
every { appCurrencyProvider() } returns AppCurrency.Default
every { isAccountsModeProvider() } returns false
every { isGaslessFeeSupportedForNetwork(any()) } returns false
sut = StateBuilder(
actions = actions,
isBalanceHiddenProvider = isBalanceHiddenProvider,
appCurrencyProvider = appCurrencyProvider,
isAccountsModeProvider = isAccountsModeProvider,
isGaslessFeeSupportedForNetwork = isGaslessFeeSupportedForNetwork,
swapFeatureToggles = swapFeatureToggles,
appRouter = appRouter,
)
}
// region createQuotesLoadingState
@Nested
inner class CreateQuotesLoadingState {
@Test
fun `GIVEN uiState has Empty sendCard WHEN called THEN returns uiState unchanged`() {
val loadingState = sut.createInitialLoadingState()
val fromStatus = buildSwapCurrencyStatus(coldWallet)
val toStatus = buildSwapCurrencyStatus(coldWallet)
val result = sut.createQuotesLoadingState(
fromSwapCurrencyStatus = fromStatus,
toSwapCurrencyStatus = toStatus,
uiStateHolder = loadingState,
)
assertThat(result).isSameInstanceAs(loadingState)
}
@Test
fun `GIVEN valid SwapCardData state WHEN called THEN providerState is Loading`() {
val baseState = buildReadyState(coldWallet)
val fromStatus = buildSwapCurrencyStatus(coldWallet)
val toStatus = buildSwapCurrencyStatus(coldWallet)
val result = sut.createQuotesLoadingState(
fromSwapCurrencyStatus = fromStatus,
toSwapCurrencyStatus = toStatus,
uiStateHolder = baseState,
)
assertThat(result.providerState).isInstanceOf(ProviderState.Loading::class.java)
}
@Test
fun `GIVEN valid state WHEN called THEN changeCardsButtonState is UPDATE_IN_PROGRESS`() {
val baseState = buildReadyState(coldWallet)
val fromStatus = buildSwapCurrencyStatus(coldWallet)
val toStatus = buildSwapCurrencyStatus(coldWallet)
val result = sut.createQuotesLoadingState(
fromSwapCurrencyStatus = fromStatus,
toSwapCurrencyStatus = toStatus,
uiStateHolder = baseState,
)
assertThat(result.changeCardsButtonState).isEqualTo(ChangeCardsButtonState.UPDATE_IN_PROGRESS)
}
@Test
fun `GIVEN valid state WHEN called THEN swapButton is disabled`() {
val baseState = buildReadyState(coldWallet)
val fromStatus = buildSwapCurrencyStatus(coldWallet)
val toStatus = buildSwapCurrencyStatus(coldWallet)
val result = sut.createQuotesLoadingState(
fromSwapCurrencyStatus = fromStatus,
toSwapCurrencyStatus = toStatus,
uiStateHolder = baseState,
)
assertThat(result.swapButton.isEnabled).isFalse()
}
@Test
fun `GIVEN valid state WHEN called THEN fee is Empty`() {
val baseState = buildReadyState(coldWallet)
val fromStatus = buildSwapCurrencyStatus(coldWallet)
val toStatus = buildSwapCurrencyStatus(coldWallet)
val result = sut.createQuotesLoadingState(
fromSwapCurrencyStatus = fromStatus,
toSwapCurrencyStatus = toStatus,
uiStateHolder = baseState,
)
assertThat(result.fee).isInstanceOf(FeeItemState.Empty::class.java)
}
@Test
fun `GIVEN valid state WHEN called THEN notifications is cleared`() {
val baseState = buildReadyState(coldWallet)
val fromStatus = buildSwapCurrencyStatus(coldWallet)
val toStatus = buildSwapCurrencyStatus(coldWallet)
val result = sut.createQuotesLoadingState(
fromSwapCurrencyStatus = fromStatus,
toSwapCurrencyStatus = toStatus,
uiStateHolder = baseState,
)
assertThat(result.notifications).isEmpty()
}
@Test
fun `GIVEN hot wallet WHEN called THEN swapButton isHoldToConfirm is true`() {
val baseState = buildReadyState(hotWallet)
val fromStatus = buildSwapCurrencyStatus(hotWallet)
val toStatus = buildSwapCurrencyStatus(hotWallet)
val result = sut.createQuotesLoadingState(
fromSwapCurrencyStatus = fromStatus,
toSwapCurrencyStatus = toStatus,
uiStateHolder = baseState,
)
assertThat(result.swapButton.isHoldToConfirm).isTrue()
}
@Test
fun `GIVEN valid state WHEN called THEN receiveCardData amountTextFieldValue is null`() {
val baseState = buildReadyState(coldWallet)
val fromStatus = buildSwapCurrencyStatus(coldWallet)
val toStatus = buildSwapCurrencyStatus(coldWallet)
val result = sut.createQuotesLoadingState(
fromSwapCurrencyStatus = fromStatus,
toSwapCurrencyStatus = toStatus,
uiStateHolder = baseState,
)
val receiveCard = result.receiveCardData as? SwapCardState.SwapCardData
assertThat(receiveCard?.amountTextFieldValue).isNull()
}
}
// endregion
// region createQuotesLoadedState
@Nested
inner class CreateQuotesLoadedState {
@Test
fun `GIVEN uiState has Empty sendCard WHEN called THEN returns uiState unchanged`() {
val loadingState = sut.createInitialLoadingState()
val quoteModel = buildQuoteModel(coldWallet, isBalanceEnough = true)
val swapProvider = buildSwapProvider()
val result = sut.createQuotesLoadedState(
uiStateHolder = loadingState,
quoteModel = quoteModel,
feeCryptoCurrencyStatus = null,
swapProvider = swapProvider,
bestRatedProviderId = "provider-id",
isNeedBestRateBadge = false,
selectedFeeType = FeeType.NORMAL,
needApplyFCARestrictions = false,
hideFee = false,
)
assertThat(result).isSameInstanceAs(loadingState)
}
@Test
fun `GIVEN valid state with hideFee true WHEN called THEN fee is Empty`() {
val baseState = buildReadyState(coldWallet)
val quoteModel = buildQuoteModel(coldWallet, isBalanceEnough = true)
val swapProvider = buildSwapProvider()
val result = sut.createQuotesLoadedState(
uiStateHolder = baseState,
quoteModel = quoteModel,
feeCryptoCurrencyStatus = null,
swapProvider = swapProvider,
bestRatedProviderId = "provider-id",
isNeedBestRateBadge = false,
selectedFeeType = FeeType.NORMAL,
needApplyFCARestrictions = false,
hideFee = true,
)
assertThat(result.fee).isInstanceOf(FeeItemState.Empty::class.java)
}
@Test
fun `GIVEN valid state with hideFee false and single fee WHEN called THEN fee is Content`() {
val baseState = buildReadyState(coldWallet)
val quoteModel = buildQuoteModel(
userWallet = coldWallet,
isBalanceEnough = true,
txFeeState = TxFeeState.SingleFeeState(fee = buildTxFeeLegacy(FeeType.NORMAL)),
)
val swapProvider = buildSwapProvider()
val result = sut.createQuotesLoadedState(
uiStateHolder = baseState,
quoteModel = quoteModel,
feeCryptoCurrencyStatus = null,
swapProvider = swapProvider,
bestRatedProviderId = "provider-id",
isNeedBestRateBadge = false,
selectedFeeType = FeeType.NORMAL,
needApplyFCARestrictions = false,
hideFee = false,
)
assertThat(result.fee).isInstanceOf(FeeItemState.Content::class.java)
}
@Test
fun `GIVEN valid state with sufficient balance WHEN called THEN isInsufficientFunds is false`() {
val baseState = buildReadyState(coldWallet)
val quoteModel = buildQuoteModel(coldWallet, isBalanceEnough = true)
val swapProvider = buildSwapProvider()
val result = sut.createQuotesLoadedState(
uiStateHolder = baseState,
quoteModel = quoteModel,
feeCryptoCurrencyStatus = null,
swapProvider = swapProvider,
bestRatedProviderId = "provider-id",
isNeedBestRateBadge = false,
selectedFeeType = FeeType.NORMAL,
needApplyFCARestrictions = false,
hideFee = false,
)
assertThat(result.isInsufficientFunds).isFalse()
}
@Test
fun `GIVEN valid state with insufficient balance WHEN called THEN isInsufficientFunds is true`() {
val baseState = buildReadyState(coldWallet)
val quoteModel = buildQuoteModel(
coldWallet,
isBalanceEnough = false,
includeFeeInAmount = IncludeFeeInAmount.Excluded,
)
val swapProvider = buildSwapProvider()
val result = sut.createQuotesLoadedState(
uiStateHolder = baseState,
quoteModel = quoteModel,
feeCryptoCurrencyStatus = null,
swapProvider = swapProvider,
bestRatedProviderId = "provider-id",
isNeedBestRateBadge = false,
selectedFeeType = FeeType.NORMAL,
needApplyFCARestrictions = false,
hideFee = false,
)
assertThat(result.isInsufficientFunds).isTrue()
}
@Test
fun `GIVEN valid state WHEN called THEN changeCardsButtonState is ENABLED`() {
val baseState = buildReadyState(coldWallet)
val quoteModel = buildQuoteModel(coldWallet, isBalanceEnough = true)
val swapProvider = buildSwapProvider()
val result = sut.createQuotesLoadedState(
uiStateHolder = baseState,
quoteModel = quoteModel,
feeCryptoCurrencyStatus = null,
swapProvider = swapProvider,
bestRatedProviderId = "provider-id",
isNeedBestRateBadge = false,
selectedFeeType = FeeType.NORMAL,
needApplyFCARestrictions = false,
hideFee = false,
)
assertThat(result.changeCardsButtonState).isEqualTo(ChangeCardsButtonState.ENABLED)
}
@Test
fun `GIVEN valid state with hot wallet WHEN called THEN swapButton isHoldToConfirm is true`() {
val baseState = buildReadyState(hotWallet)
val quoteModel = buildQuoteModel(hotWallet, isBalanceEnough = true)
val swapProvider = buildSwapProvider()
val result = sut.createQuotesLoadedState(
uiStateHolder = baseState,
quoteModel = quoteModel,
feeCryptoCurrencyStatus = null,
swapProvider = swapProvider,
bestRatedProviderId = "provider-id",
isNeedBestRateBadge = false,
selectedFeeType = FeeType.NORMAL,
needApplyFCARestrictions = false,
hideFee = false,
)
assertThat(result.swapButton.isHoldToConfirm).isTrue()
}
@Test
fun `GIVEN provider with termsOfUse WHEN called THEN tosState has tosLink`() {
val baseState = buildReadyState(coldWallet)
val quoteModel = buildQuoteModel(coldWallet, isBalanceEnough = true)
val swapProvider = buildSwapProvider(termsOfUse = "https://example.com/tos")
val result = sut.createQuotesLoadedState(
uiStateHolder = baseState,
quoteModel = quoteModel,
feeCryptoCurrencyStatus = null,
swapProvider = swapProvider,
bestRatedProviderId = "provider-id",
isNeedBestRateBadge = false,
selectedFeeType = FeeType.NORMAL,
needApplyFCARestrictions = false,
hideFee = false,
)
assertThat(result.tosState?.tosLink).isNotNull()
}
@Test
fun `GIVEN provider without termsOfUse WHEN called THEN tosState has null tosLink`() {
val baseState = buildReadyState(coldWallet)
val quoteModel = buildQuoteModel(coldWallet, isBalanceEnough = true)
val swapProvider = buildSwapProvider(termsOfUse = null)
val result = sut.createQuotesLoadedState(
uiStateHolder = baseState,
quoteModel = quoteModel,
feeCryptoCurrencyStatus = null,
swapProvider = swapProvider,
bestRatedProviderId = "provider-id",
isNeedBestRateBadge = false,
selectedFeeType = FeeType.NORMAL,
needApplyFCARestrictions = false,
hideFee = false,
)
assertThat(result.tosState?.tosLink).isNull()
}
@Test
fun `GIVEN no blocking notifications WHEN called THEN swapButton is enabled`() {
val baseState = buildReadyState(coldWallet)
val quoteModel = buildQuoteModel(coldWallet, isBalanceEnough = true)
val swapProvider = buildSwapProvider()
val result = sut.createQuotesLoadedState(
uiStateHolder = baseState,
quoteModel = quoteModel,
feeCryptoCurrencyStatus = null,
swapProvider = swapProvider,
bestRatedProviderId = "provider-id",
isNeedBestRateBadge = false,
selectedFeeType = FeeType.NORMAL,
needApplyFCARestrictions = false,
hideFee = false,
)
assertThat(result.swapButton.isEnabled).isTrue()
}
@Test
fun `GIVEN multiple fee state WHEN called THEN fee is Content with isClickable true`() {
val baseState = buildReadyState(coldWallet)
val quoteModel = buildQuoteModel(
userWallet = coldWallet,
isBalanceEnough = true,
txFeeState = TxFeeState.MultipleFeeState(
normalFee = buildTxFeeLegacy(FeeType.NORMAL),
priorityFee = buildTxFeeLegacy(FeeType.PRIORITY),
),
)
val swapProvider = buildSwapProvider()
val result = sut.createQuotesLoadedState(
uiStateHolder = baseState,
quoteModel = quoteModel,
feeCryptoCurrencyStatus = null,
swapProvider = swapProvider,
bestRatedProviderId = "provider-id",
isNeedBestRateBadge = false,
selectedFeeType = FeeType.NORMAL,
needApplyFCARestrictions = false,
hideFee = false,
)
val feeContent = result.fee as? FeeItemState.Content
assertThat(feeContent?.isClickable).isTrue()
}
}
// endregion
// region createQuotesErrorState
@Nested
inner class CreateQuotesErrorState {
@Test
fun `GIVEN uiState has Empty sendCard WHEN called THEN returns uiState unchanged`() {
val loadingState = sut.createInitialLoadingState()
val fromStatus = buildSwapCurrencyStatus(coldWallet)
val fromTokenInfo = TokenSwapInfo(
tokenAmount = buildSwapAmount(),
amountFiat = BigDecimal.ZERO,
swapCurrencyStatus = fromStatus,
)
val swapProvider = buildSwapProvider()
val result = sut.createQuotesErrorState(
uiStateHolder = loadingState,
swapProvider = swapProvider,
fromToken = fromTokenInfo,
toSwapCurrencyStatus = null,
includeFeeInAmount = IncludeFeeInAmount.Excluded,
expressDataError = ExpressDataError.UnknownError,
needApplyFCARestrictions = false,
)
assertThat(result).isSameInstanceAs(loadingState)
}
@Test
fun `GIVEN valid state WHEN called THEN swapButton is disabled`() {
val baseState = buildReadyState(coldWallet)
val fromStatus = buildSwapCurrencyStatus(coldWallet)
val fromTokenInfo = buildTokenSwapInfo(fromStatus)
val swapProvider = buildSwapProvider()
val result = sut.createQuotesErrorState(
uiStateHolder = baseState,
swapProvider = swapProvider,
fromToken = fromTokenInfo,
toSwapCurrencyStatus = null,
includeFeeInAmount = IncludeFeeInAmount.Excluded,
expressDataError = ExpressDataError.UnknownError,
needApplyFCARestrictions = false,
)
assertThat(result.swapButton.isEnabled).isFalse()
}
@Test
fun `GIVEN valid state WHEN called THEN fee is Empty`() {
val baseState = buildReadyState(coldWallet)
val fromStatus = buildSwapCurrencyStatus(coldWallet)
val fromTokenInfo = buildTokenSwapInfo(fromStatus)
val swapProvider = buildSwapProvider()
val result = sut.createQuotesErrorState(
uiStateHolder = baseState,
swapProvider = swapProvider,
fromToken = fromTokenInfo,
toSwapCurrencyStatus = null,
includeFeeInAmount = IncludeFeeInAmount.Excluded,
expressDataError = ExpressDataError.UnknownError,
needApplyFCARestrictions = false,
)
assertThat(result.fee).isInstanceOf(FeeItemState.Empty::class.java)
}
@Test
fun `GIVEN valid state WHEN called THEN permissionUM is Empty`() {
val baseState = buildReadyState(coldWallet)
val fromStatus = buildSwapCurrencyStatus(coldWallet)
val fromTokenInfo = buildTokenSwapInfo(fromStatus)
val swapProvider = buildSwapProvider()
val result = sut.createQuotesErrorState(
uiStateHolder = baseState,
swapProvider = swapProvider,
fromToken = fromTokenInfo,
toSwapCurrencyStatus = null,
includeFeeInAmount = IncludeFeeInAmount.Excluded,
expressDataError = ExpressDataError.UnknownError,
needApplyFCARestrictions = false,
)
assertThat(result.permissionUM).isEqualTo(SwapPermissionUM.Empty)
}
@Test
fun `GIVEN toSwapCurrencyStatus null WHEN called THEN receiveCardData is Empty`() {
val baseState = buildReadyState(coldWallet)
val fromStatus = buildSwapCurrencyStatus(coldWallet)
val fromTokenInfo = buildTokenSwapInfo(fromStatus)
val swapProvider = buildSwapProvider()
val result = sut.createQuotesErrorState(
uiStateHolder = baseState,
swapProvider = swapProvider,
fromToken = fromTokenInfo,
toSwapCurrencyStatus = null,
includeFeeInAmount = IncludeFeeInAmount.Excluded,
expressDataError = ExpressDataError.UnknownError,
needApplyFCARestrictions = false,
)
assertThat(result.receiveCardData).isInstanceOf(SwapCardState.Empty::class.java)
}
@Test
fun `GIVEN toSwapCurrencyStatus non-null WHEN called THEN receiveCardData is SwapCardData`() {
val baseState = buildReadyState(coldWallet)
val fromStatus = buildSwapCurrencyStatus(coldWallet)
val toStatus = buildSwapCurrencyStatus(coldWallet)
val fromTokenInfo = buildTokenSwapInfo(fromStatus)
val swapProvider = buildSwapProvider()
val result = sut.createQuotesErrorState(
uiStateHolder = baseState,
swapProvider = swapProvider,
fromToken = fromTokenInfo,
toSwapCurrencyStatus = toStatus,
includeFeeInAmount = IncludeFeeInAmount.Excluded,
expressDataError = ExpressDataError.UnknownError,
needApplyFCARestrictions = false,
)
assertThat(result.receiveCardData).isInstanceOf(SwapCardState.SwapCardData::class.java)
}
@Test
fun `GIVEN ExchangeTooSmallAmountError WHEN called THEN providerState is Content`() {
val baseState = buildReadyState(coldWallet)
val fromStatus = buildSwapCurrencyStatus(coldWallet)
val fromTokenInfo = buildTokenSwapInfo(fromStatus)
val swapProvider = buildSwapProvider()
val result = sut.createQuotesErrorState(
uiStateHolder = baseState,
swapProvider = swapProvider,
fromToken = fromTokenInfo,
toSwapCurrencyStatus = null,
includeFeeInAmount = IncludeFeeInAmount.Excluded,
expressDataError = ExpressDataError.ExchangeTooSmallAmountError(
amount = buildSwapAmount(),
code = 100,
),
needApplyFCARestrictions = false,
)
assertThat(result.providerState).isInstanceOf(ProviderState.Content::class.java)
}
@Test
fun `GIVEN UnknownError WHEN called THEN providerState is Empty`() {
val baseState = buildReadyState(coldWallet)
val fromStatus = buildSwapCurrencyStatus(coldWallet)
val fromTokenInfo = buildTokenSwapInfo(fromStatus)
val swapProvider = buildSwapProvider()
val result = sut.createQuotesErrorState(
uiStateHolder = baseState,
swapProvider = swapProvider,
fromToken = fromTokenInfo,
toSwapCurrencyStatus = null,
includeFeeInAmount = IncludeFeeInAmount.Excluded,
expressDataError = ExpressDataError.UnknownError,
needApplyFCARestrictions = false,
)
assertThat(result.providerState).isInstanceOf(ProviderState.Empty::class.java)
}
}
// endregion
// region createQuotesEmptyAmountState
@Nested
inner class CreateQuotesEmptyAmountState {
@Test
fun `GIVEN uiState has Empty sendCard WHEN called THEN returns uiState unchanged`() {
val loadingState = sut.createInitialLoadingState()
val result = sut.createQuotesEmptyAmountState(
uiStateHolder = loadingState,
emptyAmountState = emptyAmountState,
fromSwapCurrencyStatus = null,
)
assertThat(result).isSameInstanceAs(loadingState)
}
@Test
fun `GIVEN valid SwapCardData state WHEN called THEN swapButton is disabled`() {
val baseState = buildReadyState(coldWallet)
val result = sut.createQuotesEmptyAmountState(
uiStateHolder = baseState,
emptyAmountState = emptyAmountState,
fromSwapCurrencyStatus = null,
)
assertThat(result.swapButton.isEnabled).isFalse()
}
@Test
fun `GIVEN valid state WHEN called THEN notifications is empty`() {
val baseState = buildReadyState(coldWallet)
val result = sut.createQuotesEmptyAmountState(
uiStateHolder = baseState,
emptyAmountState = emptyAmountState,
fromSwapCurrencyStatus = null,
)
assertThat(result.notifications).isEmpty()
}
@Test
fun `GIVEN valid state WHEN called THEN isInsufficientFunds is false`() {
val baseState = buildReadyState(coldWallet)
val result = sut.createQuotesEmptyAmountState(
uiStateHolder = baseState,
emptyAmountState = emptyAmountState,
fromSwapCurrencyStatus = null,
)
assertThat(result.isInsufficientFunds).isFalse()
}
@Test
fun `GIVEN valid state WHEN called THEN fee is Empty`() {
val baseState = buildReadyState(coldWallet)
val result = sut.createQuotesEmptyAmountState(
uiStateHolder = baseState,
emptyAmountState = emptyAmountState,
fromSwapCurrencyStatus = null,
)
assertThat(result.fee).isInstanceOf(FeeItemState.Empty::class.java)
}
@Test
fun `GIVEN valid state WHEN called THEN changeCardsButtonState is ENABLED`() {
val baseState = buildReadyState(coldWallet)
val result = sut.createQuotesEmptyAmountState(
uiStateHolder = baseState,
emptyAmountState = emptyAmountState,
fromSwapCurrencyStatus = null,
)
assertThat(result.changeCardsButtonState).isEqualTo(ChangeCardsButtonState.ENABLED)
}
@Test
fun `GIVEN valid state WHEN called THEN providerState is Empty`() {
val baseState = buildReadyState(coldWallet)
val result = sut.createQuotesEmptyAmountState(
uiStateHolder = baseState,
emptyAmountState = emptyAmountState,
fromSwapCurrencyStatus = null,
)
assertThat(result.providerState).isInstanceOf(ProviderState.Empty::class.java)
}
@Test
fun `GIVEN valid state WHEN called THEN receiveCard amountTextFieldValue is 0`() {
val baseState = buildReadyState(coldWallet)
val result = sut.createQuotesEmptyAmountState(
uiStateHolder = baseState,
emptyAmountState = emptyAmountState,
fromSwapCurrencyStatus = null,
)
val receiveCard = result.receiveCardData as? SwapCardState.SwapCardData
assertThat(receiveCard?.amountTextFieldValue?.text).isEqualTo("0")
}
@Test
fun `GIVEN fromSwapCurrencyStatus with hot wallet WHEN called THEN swapButton isHoldToConfirm is true`() {
val baseState = buildReadyState(hotWallet)
val fromStatus = buildSwapCurrencyStatus(hotWallet)
val result = sut.createQuotesEmptyAmountState(
uiStateHolder = baseState,
emptyAmountState = emptyAmountState,
fromSwapCurrencyStatus = fromStatus,
)
assertThat(result.swapButton.isHoldToConfirm).isTrue()
}
}
// endregion
// --- Helpers ---
private fun buildReadyState(userWallet: UserWallet): SwapStateHolder {
val fromStatus = buildSwapCurrencyStatus(userWallet)
val toStatus = buildSwapCurrencyStatus(userWallet)
return sut.createInitialReadyState(
uiStateHolder = sut.createInitialLoadingState(),
emptyAmountState = emptyAmountState,
fromSwapCurrencyStatus = fromStatus,
toSwapCurrencyStatus = toStatus,
)
}
private fun buildQuoteModel(
userWallet: UserWallet,
isBalanceEnough: Boolean,
includeFeeInAmount: IncludeFeeInAmount = IncludeFeeInAmount.Excluded,
txFeeState: TxFeeState = TxFeeState.Empty,
): SwapState.QuotesLoadedState {
val fromStatus = buildSwapCurrencyStatus(userWallet)
val toStatus = buildSwapCurrencyStatus(userWallet)
val fromTokenInfo = TokenSwapInfo(
tokenAmount = buildSwapAmount(),
amountFiat = BigDecimal("100.00"),
swapCurrencyStatus = fromStatus,
)
val toTokenInfo = TokenSwapInfo(
tokenAmount = buildSwapAmount(value = BigDecimal("0.05")),
amountFiat = BigDecimal("100.00"),
swapCurrencyStatus = toStatus,
)
return SwapState.QuotesLoadedState(
fromTokenInfo = fromTokenInfo,
toTokenInfo = toTokenInfo,
priceImpact = PriceImpact.Empty,
preparedSwapConfigState = PreparedSwapConfigState(
isBalanceEnough = isBalanceEnough,
feeState = SwapFeeState.Enough,
hasOutgoingTransaction = false,
includeFeeInAmount = includeFeeInAmount,
),
permissionState = PermissionDataState.Empty,
txFee = txFeeState,
currencyCheck = null,
validationResult = null,
minAdaValue = null,
swapProvider = buildSwapProvider(),
)
}
private fun buildSwapProvider(
termsOfUse: String? = null,
privacyPolicy: String? = null,
) = SwapProvider(
providerId = "provider-id",
name = "TestProvider",
type = ExchangeProviderType.DEX,
imageLarge = "https://example.com/icon.png",
termsOfUse = termsOfUse,
privacyPolicy = privacyPolicy,
isRecommended = false,
slippage = null,
)
private fun buildSwapAmount(value: BigDecimal = BigDecimal("1.0")) = SwapAmount(
value = value,
decimals = 18,
)
private fun buildTokenSwapInfo(swapCurrencyStatus: SwapCurrencyStatus) = TokenSwapInfo(
tokenAmount = buildSwapAmount(),
amountFiat = BigDecimal.ZERO,
swapCurrencyStatus = swapCurrencyStatus,
)
private fun buildTxFeeLegacy(feeType: FeeType): TxFee.Legacy {
val fee: com.tangem.blockchain.common.transaction.Fee = mockk(relaxed = true)
return TxFee.Legacy(
feeValue = BigDecimal("0.001"),
feeFiatFormatted = "$2.00",
feeCryptoFormatted = "0.001 ETH",
feeIncludeOtherNativeFee = BigDecimal.ZERO,
feeFiatFormattedWithNative = "$2.00",
feeCryptoFormattedWithNative = "0.001 ETH",
cryptoSymbol = "ETH",
feeType = feeType,
fee = fee,
)
}
}

View file

@ -1,614 +0,0 @@
package com.tangem.feature.swap
import com.google.common.truth.Truth.assertThat
import com.tangem.common.routing.AppRouter
import com.tangem.domain.appcurrency.model.AppCurrency
import com.tangem.domain.models.wallet.UserWallet
import com.tangem.domain.models.wallet.UserWalletId
import com.tangem.domain.transaction.usecase.gasless.IsGaslessFeeSupportedForNetwork
import com.tangem.feature.swap.domain.models.domain.*
import com.tangem.feature.swap.domain.models.ui.*
import com.tangem.feature.swap.model.SwapProcessDataState
import com.tangem.feature.swap.models.*
import com.tangem.feature.swap.models.states.ProviderState
import com.tangem.feature.swap.models.states.SwapNotificationUM
import com.tangem.feature.swap.ui.StateBuilder
import com.tangem.features.swap.SwapFeatureToggles
import com.tangem.utils.Provider
import io.mockk.every
import io.mockk.mockk
import kotlinx.collections.immutable.persistentListOf
import kotlinx.collections.immutable.toImmutableList
import org.junit.jupiter.api.BeforeEach
import org.junit.jupiter.api.Nested
import org.junit.jupiter.api.Test
import org.junit.jupiter.params.ParameterizedTest
import org.junit.jupiter.params.provider.EnumSource
import java.math.BigDecimal
internal class StateBuilderSwapDataTest {
private val actions: UiActions = mockk(relaxed = true)
private val isBalanceHiddenProvider: Provider<Boolean> = mockk()
private val appCurrencyProvider: Provider<AppCurrency> = mockk()
private val isAccountsModeProvider: Provider<Boolean> = mockk()
private val isGaslessFeeSupportedForNetwork: IsGaslessFeeSupportedForNetwork = mockk()
private val swapFeatureToggles: SwapFeatureToggles = mockk(relaxed = true)
private val appRouter: AppRouter = mockk()
private lateinit var sut: StateBuilder
private val userWalletId = UserWalletId("aabbccdd")
private val coldWallet: UserWallet.Cold = mockk(relaxed = true) {
every { walletId } returns userWalletId
}
private val hotWallet: UserWallet.Hot = mockk(relaxed = true) {
every { walletId } returns userWalletId
}
private val emptyAmountState = SwapState.EmptyAmountState(
zeroAmountEquivalent = com.tangem.core.ui.extensions.stringReference("$0.00"),
)
@BeforeEach
fun setup() {
every { isBalanceHiddenProvider() } returns false
every { appCurrencyProvider() } returns AppCurrency.Default
every { isAccountsModeProvider() } returns false
every { isGaslessFeeSupportedForNetwork(any()) } returns false
sut = StateBuilder(
actions = actions,
isBalanceHiddenProvider = isBalanceHiddenProvider,
appCurrencyProvider = appCurrencyProvider,
isAccountsModeProvider = isAccountsModeProvider,
isGaslessFeeSupportedForNetwork = isGaslessFeeSupportedForNetwork,
swapFeatureToggles = swapFeatureToggles,
appRouter = appRouter,
)
}
// region createSwapInProgressState
@Nested
inner class CreateSwapInProgressState {
@Test
fun `WHEN called THEN swapButton isInProgress becomes true`() {
val baseState = buildReadyState(coldWallet)
val result = sut.createSwapInProgressState(baseState)
assertThat(result.swapButton.isInProgress).isTrue()
}
@Test
fun `WHEN called THEN swapButton isEnabled becomes false`() {
val baseState = buildReadyState(coldWallet)
// force enable the button by overriding manually
val stateWithEnabled = baseState.copy(
swapButton = baseState.swapButton.copy(isEnabled = true),
)
val result = sut.createSwapInProgressState(stateWithEnabled)
assertThat(result.swapButton.isEnabled).isFalse()
}
@Test
fun `WHEN called THEN all other fields remain unchanged`() {
val baseState = buildReadyState(coldWallet)
val result = sut.createSwapInProgressState(baseState)
assertThat(result.sendCardData).isEqualTo(baseState.sendCardData)
assertThat(result.receiveCardData).isEqualTo(baseState.receiveCardData)
assertThat(result.fee).isEqualTo(baseState.fee)
assertThat(result.changeCardsButtonState).isEqualTo(baseState.changeCardsButtonState)
}
}
// endregion
// region createSilentLoadState
@Nested
inner class CreateSilentLoadState {
@Test
fun `WHEN called THEN changeCardsButtonState is UPDATE_IN_PROGRESS`() {
val baseState = buildReadyState(coldWallet)
val result = sut.createSilentLoadState(baseState)
assertThat(result.changeCardsButtonState).isEqualTo(ChangeCardsButtonState.UPDATE_IN_PROGRESS)
}
@Test
fun `GIVEN notifications without PermissionNeeded WHEN called THEN notifications remain unchanged`() {
val errorNotification = SwapNotificationUM.Warning.SwapNotSupported
val baseState = buildReadyState(coldWallet).copy(
notifications = persistentListOf(errorNotification),
)
val result = sut.createSilentLoadState(baseState)
assertThat(result.notifications).hasSize(1)
assertThat(result.notifications[0]).isEqualTo(errorNotification)
}
@Test
fun `GIVEN notifications with PermissionNeeded WHEN called THEN PermissionNeeded is removed`() {
val permissionNeeded = SwapNotificationUM.Info.PermissionNeeded(
onApproveClick = {},
onLearnMoreClick = {},
)
val otherNotification = SwapNotificationUM.Warning.SwapNotSupported
val baseState = buildReadyState(coldWallet).copy(
notifications = listOf(permissionNeeded, otherNotification).toImmutableList(),
)
val result = sut.createSilentLoadState(baseState)
assertThat(result.notifications).hasSize(1)
assertThat(result.notifications[0]).isEqualTo(otherNotification)
}
}
// endregion
// region updateSwapAmount
@Nested
inner class UpdateSwapAmount {
@Test
fun `GIVEN uiState has Empty sendCard WHEN called THEN returns uiState unchanged`() {
val loadingState = sut.createInitialLoadingState()
val fromStatus = buildSwapCurrencyStatus(coldWallet)
val result = sut.updateSwapAmount(
uiState = loadingState,
amountFormatted = "1.5",
amountRaw = "1.5",
fromSwapCurrencyStatus = fromStatus,
minTxAmount = null,
)
assertThat(result).isSameInstanceAs(loadingState)
}
@Test
fun `GIVEN amount is above minTxAmount WHEN called THEN inputError is Empty`() {
val baseState = buildReadyState(coldWallet)
val fromStatus = buildSwapCurrencyStatus(coldWallet)
val result = sut.updateSwapAmount(
uiState = baseState,
amountFormatted = "2.0",
amountRaw = "2.0",
fromSwapCurrencyStatus = fromStatus,
minTxAmount = BigDecimal("1.0"),
)
val sendCard = result.sendCardData as? SwapCardState.SwapCardData
val inputtable = sendCard?.type as? TransactionCardType.Inputtable
assertThat(inputtable?.inputError).isEqualTo(TransactionCardType.InputError.Empty)
}
@Test
fun `GIVEN amount is below minTxAmount WHEN called THEN inputError is WrongAmount`() {
val baseState = buildReadyState(coldWallet)
val fromStatus = buildSwapCurrencyStatus(coldWallet)
val result = sut.updateSwapAmount(
uiState = baseState,
amountFormatted = "0.5",
amountRaw = "0.5",
fromSwapCurrencyStatus = fromStatus,
minTxAmount = BigDecimal("1.0"),
)
val sendCard = result.sendCardData as? SwapCardState.SwapCardData
val inputtable = sendCard?.type as? TransactionCardType.Inputtable
assertThat(inputtable?.inputError).isEqualTo(TransactionCardType.InputError.WrongAmount)
}
@Test
fun `GIVEN minTxAmount is null WHEN called THEN inputError is Empty`() {
val baseState = buildReadyState(coldWallet)
val fromStatus = buildSwapCurrencyStatus(coldWallet)
val result = sut.updateSwapAmount(
uiState = baseState,
amountFormatted = "0.001",
amountRaw = "0.001",
fromSwapCurrencyStatus = fromStatus,
minTxAmount = null,
)
val sendCard = result.sendCardData as? SwapCardState.SwapCardData
val inputtable = sendCard?.type as? TransactionCardType.Inputtable
assertThat(inputtable?.inputError).isEqualTo(TransactionCardType.InputError.Empty)
}
@Test
fun `WHEN called THEN sendCardData amountTextFieldValue text is updated`() {
val baseState = buildReadyState(coldWallet)
val fromStatus = buildSwapCurrencyStatus(coldWallet)
val result = sut.updateSwapAmount(
uiState = baseState,
amountFormatted = "3.14",
amountRaw = "3.14",
fromSwapCurrencyStatus = fromStatus,
minTxAmount = null,
)
val sendCard = result.sendCardData as? SwapCardState.SwapCardData
assertThat(sendCard?.amountTextFieldValue?.text).isEqualTo("3.14")
}
}
// endregion
// region updateBalanceHiddenState
@Nested
inner class UpdateBalanceHiddenState {
@Test
fun `GIVEN isBalanceHidden true WHEN called THEN sendCardData isBalanceHidden is true`() {
val fromStatus = buildSwapCurrencyStatus(coldWallet)
val toStatus = buildSwapCurrencyStatus(coldWallet)
val baseState = sut.createInitialReadyState(
uiStateHolder = sut.createInitialLoadingState(),
emptyAmountState = emptyAmountState,
fromSwapCurrencyStatus = fromStatus,
toSwapCurrencyStatus = toStatus,
)
val result = sut.updateBalanceHiddenState(baseState, isBalanceHidden = true)
val sendCard = result.sendCardData as? SwapCardState.SwapCardData
assertThat(sendCard?.isBalanceHidden).isTrue()
}
@Test
fun `GIVEN isBalanceHidden true WHEN called THEN receiveCardData isBalanceHidden is true`() {
val fromStatus = buildSwapCurrencyStatus(coldWallet)
val toStatus = buildSwapCurrencyStatus(coldWallet)
val baseState = sut.createInitialReadyState(
uiStateHolder = sut.createInitialLoadingState(),
emptyAmountState = emptyAmountState,
fromSwapCurrencyStatus = fromStatus,
toSwapCurrencyStatus = toStatus,
)
val result = sut.updateBalanceHiddenState(baseState, isBalanceHidden = true)
val receiveCard = result.receiveCardData as? SwapCardState.SwapCardData
assertThat(receiveCard?.isBalanceHidden).isTrue()
}
@Test
fun `GIVEN isBalanceHidden false WHEN called THEN both cards isBalanceHidden is false`() {
val fromStatus = buildSwapCurrencyStatus(coldWallet)
val toStatus = buildSwapCurrencyStatus(coldWallet)
val baseState = sut.createInitialReadyState(
uiStateHolder = sut.createInitialLoadingState(),
emptyAmountState = emptyAmountState,
fromSwapCurrencyStatus = fromStatus,
toSwapCurrencyStatus = toStatus,
)
val result = sut.updateBalanceHiddenState(baseState, isBalanceHidden = false)
val sendCard = result.sendCardData as? SwapCardState.SwapCardData
val receiveCard = result.receiveCardData as? SwapCardState.SwapCardData
assertThat(sendCard?.isBalanceHidden).isFalse()
assertThat(receiveCard?.isBalanceHidden).isFalse()
}
@Test
fun `GIVEN sendCard is Empty type WHEN called THEN sendCard remains Empty type`() {
val loadingState = sut.createInitialLoadingState()
val result = sut.updateBalanceHiddenState(loadingState, isBalanceHidden = true)
assertThat(result.sendCardData).isInstanceOf(SwapCardState.Empty::class.java)
}
}
// endregion
// region loadingPermissionState
@Nested
inner class LoadingPermissionState {
@Test
fun `WHEN called THEN swapButton isEnabled is false`() {
val baseState = buildReadyState(coldWallet).copy(
swapButton = buildReadyState(coldWallet).swapButton.copy(isEnabled = true),
)
val result = sut.loadingPermissionState(baseState)
assertThat(result.swapButton.isEnabled).isFalse()
}
@ParameterizedTest
@EnumSource(
value = SwapButton.Mode::class,
mode = EnumSource.Mode.INCLUDE,
names = ["SWAP_PROGRESSING", "TRANSFER_PROGRESSING"],
)
fun `WHEN called THEN swapButton isInProgress is false`(mode: SwapButton.Mode) {
val baseState = buildReadyState(coldWallet).copy(
swapButton = buildReadyState(coldWallet).swapButton.copy(
mode = mode,
),
)
val result = sut.loadingPermissionState(baseState)
assertThat(result.swapButton.isInProgress).isFalse()
}
@Test
fun `GIVEN notifications without PermissionNeeded WHEN called THEN ApprovalInProgressWarning is prepended`() {
val existingNotification = SwapNotificationUM.Warning.SwapNotSupported
val baseState = buildReadyState(coldWallet).copy(
notifications = persistentListOf(existingNotification),
)
val result = sut.loadingPermissionState(baseState)
assertThat(result.notifications[0]).isInstanceOf(SwapNotificationUM.Error.ApprovalInProgressWarning::class.java)
}
@Test
fun `GIVEN notifications with PermissionNeeded WHEN called THEN PermissionNeeded is replaced by ApprovalInProgressWarning`() {
val permissionNeeded = SwapNotificationUM.Info.PermissionNeeded(
onApproveClick = {},
onLearnMoreClick = {},
)
val baseState = buildReadyState(coldWallet).copy(
notifications = persistentListOf(permissionNeeded),
)
val result = sut.loadingPermissionState(baseState)
assertThat(result.notifications).doesNotContain(permissionNeeded)
assertThat(result.notifications[0]).isInstanceOf(SwapNotificationUM.Error.ApprovalInProgressWarning::class.java)
}
}
// endregion
// region dismissBottomSheet
@Nested
inner class DismissBottomSheet {
@Test
fun `GIVEN bottomSheetConfig is null WHEN called THEN bottomSheetConfig remains null`() {
val baseState = buildReadyState(coldWallet)
assertThat(baseState.bottomSheetConfig).isNull()
val result = sut.dismissBottomSheet(baseState)
assertThat(result.bottomSheetConfig).isNull()
}
@Test
fun `GIVEN bottomSheetConfig is shown WHEN called THEN bottomSheetConfig isShown becomes false`() {
val baseState = buildReadyState(coldWallet).copy(
bottomSheetConfig = com.tangem.core.ui.components.bottomsheets.TangemBottomSheetConfig(
isShown = true,
onDismissRequest = {},
content = mockk(relaxed = true),
),
)
val result = sut.dismissBottomSheet(baseState)
assertThat(result.bottomSheetConfig?.isShown).isFalse()
}
}
// endregion
// region addNotification
@Nested
inner class AddNotification {
@Test
fun `GIVEN a message WHEN called THEN notifications contains GenericError`() {
val baseState = buildReadyState(coldWallet)
val message = com.tangem.core.ui.extensions.stringReference("Something went wrong")
val result = sut.addNotification(
uiState = baseState,
message = message,
onClick = {},
)
assertThat(result.notifications).hasSize(1)
assertThat(result.notifications[0]).isInstanceOf(SwapNotificationUM.Error.GenericError::class.java)
}
@Test
fun `GIVEN null message WHEN called THEN notifications contains GenericError`() {
val baseState = buildReadyState(coldWallet)
val result = sut.addNotification(
uiState = baseState,
message = null,
onClick = {},
)
assertThat(result.notifications).hasSize(1)
assertThat(result.notifications[0]).isInstanceOf(SwapNotificationUM.Error.GenericError::class.java)
}
}
// endregion
// region createSuccessState
@Nested
inner class CreateSuccessState {
@Test
fun `GIVEN valid state WHEN called THEN successState is not null`() {
val baseState = buildReadyStateWithContentProvider(coldWallet)
val fromStatus = buildSwapCurrencyStatus(coldWallet)
val toStatus = buildSwapCurrencyStatus(coldWallet)
val dataState = SwapProcessDataState(
fromSwapCurrencyStatus = fromStatus,
toSwapCurrencyStatus = toStatus,
selectedFee = null,
)
val swapTransactionState = buildSwapTransactionState()
val result = sut.createSuccessState(
uiState = baseState,
swapTransactionState = swapTransactionState,
dataState = dataState,
onExploreClick = {},
onStatusClick = {},
txUrl = "https://example.com/tx/abc",
)
assertThat(result.successState).isNotNull()
}
@Test
fun `GIVEN CEX provider WHEN called THEN shouldShowStatusButton is true`() {
val baseState = buildReadyStateWithContentProvider(
coldWallet,
providerType = ExchangeProviderType.CEX,
)
val fromStatus = buildSwapCurrencyStatus(coldWallet)
val toStatus = buildSwapCurrencyStatus(coldWallet)
val dataState = SwapProcessDataState(
fromSwapCurrencyStatus = fromStatus,
toSwapCurrencyStatus = toStatus,
selectedFee = null,
)
val swapTransactionState = buildSwapTransactionState()
val result = sut.createSuccessState(
uiState = baseState,
swapTransactionState = swapTransactionState,
dataState = dataState,
onExploreClick = {},
onStatusClick = {},
txUrl = "https://example.com/tx/abc",
)
assertThat(result.successState?.shouldShowStatusButton).isTrue()
}
@Test
fun `GIVEN DEX provider WHEN called THEN shouldShowStatusButton is false`() {
val baseState = buildReadyStateWithContentProvider(
coldWallet,
providerType = ExchangeProviderType.DEX,
)
val fromStatus = buildSwapCurrencyStatus(coldWallet)
val toStatus = buildSwapCurrencyStatus(coldWallet)
val dataState = SwapProcessDataState(
fromSwapCurrencyStatus = fromStatus,
toSwapCurrencyStatus = toStatus,
selectedFee = null,
)
val swapTransactionState = buildSwapTransactionState()
val result = sut.createSuccessState(
uiState = baseState,
swapTransactionState = swapTransactionState,
dataState = dataState,
onExploreClick = {},
onStatusClick = {},
txUrl = "https://example.com/tx/abc",
)
assertThat(result.successState?.shouldShowStatusButton).isFalse()
}
@Test
fun `GIVEN txUrl WHEN called THEN successState txUrl matches`() {
val baseState = buildReadyStateWithContentProvider(coldWallet)
val fromStatus = buildSwapCurrencyStatus(coldWallet)
val toStatus = buildSwapCurrencyStatus(coldWallet)
val dataState = SwapProcessDataState(
fromSwapCurrencyStatus = fromStatus,
toSwapCurrencyStatus = toStatus,
selectedFee = null,
)
val swapTransactionState = buildSwapTransactionState()
val expectedUrl = "https://etherscan.io/tx/0xabc"
val result = sut.createSuccessState(
uiState = baseState,
swapTransactionState = swapTransactionState,
dataState = dataState,
onExploreClick = {},
onStatusClick = {},
txUrl = expectedUrl,
)
assertThat(result.successState?.txUrl).isEqualTo(expectedUrl)
}
}
// endregion
// --- Helpers ---
private fun buildReadyState(userWallet: UserWallet): SwapStateHolder {
val fromStatus = buildSwapCurrencyStatus(userWallet)
val toStatus = buildSwapCurrencyStatus(userWallet)
return sut.createInitialReadyState(
uiStateHolder = sut.createInitialLoadingState(),
emptyAmountState = emptyAmountState,
fromSwapCurrencyStatus = fromStatus,
toSwapCurrencyStatus = toStatus,
)
}
private fun buildReadyStateWithContentProvider(
userWallet: UserWallet,
providerType: ExchangeProviderType = ExchangeProviderType.DEX,
): SwapStateHolder {
val baseState = buildReadyState(userWallet)
return baseState.copy(
providerState = ProviderState.Content(
id = "provider-id",
name = "TestProvider",
type = providerType.providerName,
iconUrl = "https://example.com/icon.png",
subtitle = com.tangem.core.ui.extensions.stringReference("1 ETH ≈ 2000 USDT"),
additionalBadge = ProviderState.AdditionalBadge.Empty,
selectionType = ProviderState.SelectionType.CLICK,
namePrefix = ProviderState.PrefixType.NONE,
onProviderClick = {},
),
)
}
private fun buildSwapTransactionState(): SwapTransactionState.TxSent {
return SwapTransactionState.TxSent(
fromAmount = "1.0 ETH",
toAmount = "2000 USDT",
fromAmountValue = BigDecimal("1.0"),
toAmountValue = BigDecimal("2000"),
txHash = "0xabc",
timestamp = System.currentTimeMillis(),
)
}
}

View file

@ -14,11 +14,14 @@ import com.tangem.domain.models.account.Account
import com.tangem.domain.models.wallet.UserWallet
import com.tangem.domain.models.wallet.UserWalletId
import com.tangem.domain.swap.models.SwapCurrencyStatus
import com.tangem.core.ui.extensions.TextReference
import com.tangem.core.ui.extensions.stringReference
import com.tangem.feature.swap.buildSwapCurrencyStatus
import com.tangem.feature.swap.domain.models.SwapAmount
import com.tangem.feature.swap.domain.models.ui.PriceImpact
import com.tangem.feature.swap.domain.models.ui.SwapState
import com.tangem.feature.swap.domain.models.ui.TokenSwapInfo
import com.tangem.feature.swap.model.SwapProcessDataState
import com.tangem.feature.swap.models.*
import com.tangem.feature.swap.models.states.ProviderState
import com.tangem.feature.swap.presentation.R
@ -160,6 +163,79 @@ internal class SwapTransferStateBuilderTest {
assertThat(result.swapButton.isEnabled).isFalse()
}
@Test
fun `GIVEN content uiState WHEN createTransferInProgressState THEN swap button is disabled in TRANSFER_PROGRESSING mode`() {
val initialButton = SwapButton(
walletInteractionIcon = null,
isEnabled = true,
mode = SwapButton.Mode.TRANSFER,
onClick = {},
)
val uiState = baseStateHolder().copy(swapButton = initialButton)
val result = sut.createTransferInProgressState(uiState)
assertThat(result.swapButton.isEnabled).isFalse()
assertThat(result.swapButton.mode).isEqualTo(SwapButton.Mode.TRANSFER_PROGRESSING)
assertThat(result.swapButton.walletInteractionIcon).isEqualTo(initialButton.walletInteractionIcon)
assertThat(result.swapButton.onClick).isEqualTo(initialButton.onClick)
}
@Test
fun `GIVEN dataState with from-to currencies WHEN createSuccessState THEN success holder is built in transfer mode with given fee and txUrl`() {
val appCurrency = AppCurrency(code = "USD", name = "US Dollar", symbol = "$")
val amount = BigDecimal("1.5")
val dataState = SwapProcessDataState(
fromSwapCurrencyStatus = fromCurrencyStatus,
toSwapCurrencyStatus = toCurrencyStatus,
amount = amount.toPlainString(),
)
val fee: TextReference = stringReference("0.001 ETH")
val txUrl = "https://explorer.example/tx/0xabc"
val timestamp = 1_700_000_000_000L
val result = sut.createSuccessState(
uiState = baseStateHolder(),
dataState = dataState,
appCurrency = appCurrency,
isAccountsMode = true,
txUrl = txUrl,
timestamp = timestamp,
fee = fee,
)
val success = requireNotNull(result.successState)
assertThat(success.isTransferMode).isTrue()
assertThat(success.shouldShowStatusButton).isFalse()
assertThat(success.timestamp).isEqualTo(timestamp)
assertThat(success.txUrl).isEqualTo(txUrl)
assertThat(success.fee).isEqualTo(fee)
assertThat(success.providerName).isEqualTo(TextReference.EMPTY)
assertThat(success.providerType).isEqualTo(TextReference.EMPTY)
assertThat(success.providerIcon).isEmpty()
assertThat(success.rate).isEqualTo(TextReference.EMPTY)
assertThat(success.fromTokenIconState).isEqualTo(fromIcon)
assertThat(success.toTokenIconState).isEqualTo(toIcon)
val portfolioAccount = fromCurrencyStatus.account as Account.CryptoPortfolio
val expectedIcon = CryptoPortfolioIconConverter.convert(portfolioAccount.icon)
val expectedName = portfolioAccount.accountName.toUM().value
assertThat(success.fromTitle).isEqualTo(
AccountTitleUM.Account(
prefixText = resourceReference(R.string.swapping_from_account_title),
name = expectedName,
icon = expectedIcon,
),
)
assertThat(success.toTitle).isEqualTo(
AccountTitleUM.Account(
prefixText = resourceReference(R.string.swapping_to_account_title),
name = expectedName,
icon = expectedIcon,
),
)
}
private fun assertSharedCardShape(
result: SwapStateHolder,
transferState: SwapState.Transfer,
@ -183,7 +259,7 @@ internal class SwapTransferStateBuilderTest {
assertThat(result.swapButton).isEqualTo(
SwapButton(
walletInteractionIcon = walletInterationIcon(transferState.userWallet),
isEnabled = !transferState.isInsufficientBalance,
isEnabled = false,
mode = SwapButton.Mode.TRANSFER,
onClick = actions.onTransferClick,
),