diff --git a/core/utils/src/main/java/com/tangem/utils/coroutines/PeriodicTask.kt b/core/utils/src/main/java/com/tangem/utils/coroutines/PeriodicTask.kt index ccfb61e52a..ac8a07f535 100644 --- a/core/utils/src/main/java/com/tangem/utils/coroutines/PeriodicTask.kt +++ b/core/utils/src/main/java/com/tangem/utils/coroutines/PeriodicTask.kt @@ -58,4 +58,15 @@ class SingleTaskScheduler { fun cancelTask() { lastTask?.cancel() } + + fun destroyTask() { + lastTask?.cancel() + lastTask = null + } + + fun resumeLastTask(scope: CoroutineScope) { + scope.launch { + lastTask?.runTaskWithDelay() + } + } } \ No newline at end of file diff --git a/core/utils/src/test/kotlin/com/tangem/utils/coroutines/PeriodicTaskTest.kt b/core/utils/src/test/kotlin/com/tangem/utils/coroutines/PeriodicTaskTest.kt index 1f77fe3cce..dfc2fa2b95 100644 --- a/core/utils/src/test/kotlin/com/tangem/utils/coroutines/PeriodicTaskTest.kt +++ b/core/utils/src/test/kotlin/com/tangem/utils/coroutines/PeriodicTaskTest.kt @@ -200,6 +200,133 @@ class PeriodicTaskTest { verify(exactly = 0) { onSuccess.invoke(any()) } } + @Test + fun `GIVEN no task scheduled WHEN resumeLastTask THEN no crash and no invocations`() = runTest { + val scheduler = SingleTaskScheduler() + + scheduler.resumeLastTask(backgroundScope) + advanceUntilIdle() + // No assertion needed beyond not crashing — lastTask is null, the safe-call is a no-op. + } + + @Test + fun `GIVEN scheduled task cancelled WHEN resumeLastTask THEN task resumes and is invoked again`() = runTest { + val callCount = AtomicInteger(0) + val periodicTask = PeriodicTask( + delay = PERIOD, + task = { callCount.incrementAndGet(); Result.success(VALUE) }, + onSuccess = mockk(relaxed = true), + onError = mockk(relaxed = true), + initialDelay = 0L, + ) + val scheduler = SingleTaskScheduler() + scheduler.scheduleTask(backgroundScope, periodicTask) + runCurrent() + assertThat(callCount.get()).isEqualTo(1) + scheduler.cancelTask() + advanceUntilIdle() + val countAtPause = callCount.get() + + scheduler.resumeLastTask(backgroundScope) + runCurrent() + + assertThat(callCount.get()).isEqualTo(countAtPause + 1) + scheduler.cancelTask() + } + + @Test + fun `GIVEN resumed task WHEN delay elapses THEN task continues ticking periodically`() = runTest { + val callCount = AtomicInteger(0) + val periodicTask = PeriodicTask( + delay = PERIOD, + task = { callCount.incrementAndGet(); Result.success(VALUE) }, + onSuccess = mockk(relaxed = true), + onError = mockk(relaxed = true), + initialDelay = 0L, + ) + val scheduler = SingleTaskScheduler() + scheduler.scheduleTask(backgroundScope, periodicTask) + runCurrent() + scheduler.cancelTask() + advanceUntilIdle() + val countAtPause = callCount.get() + + scheduler.resumeLastTask(backgroundScope) + runCurrent() + val countAfterResume = callCount.get() + advanceTimeBy(PERIOD) + runCurrent() + + // Immediate invocation on resume. + assertThat(countAfterResume).isEqualTo(countAtPause + 1) + // After one more PERIOD elapses, at least one additional periodic tick has fired. + assertThat(callCount.get()).isGreaterThan(countAfterResume) + scheduler.cancelTask() + } + + @Test + fun `GIVEN scheduled task WHEN destroyTask THEN task stops and resumeLastTask is a no-op`() = runTest { + val callCount = AtomicInteger(0) + val periodicTask = PeriodicTask( + delay = PERIOD, + task = { callCount.incrementAndGet(); Result.success(VALUE) }, + onSuccess = mockk(relaxed = true), + onError = mockk(relaxed = true), + initialDelay = 0L, + ) + val scheduler = SingleTaskScheduler() + scheduler.scheduleTask(backgroundScope, periodicTask) + runCurrent() + assertThat(callCount.get()).isEqualTo(1) + + scheduler.destroyTask() + advanceUntilIdle() + val countAfterDestroy = callCount.get() + + scheduler.resumeLastTask(backgroundScope) + advanceUntilIdle() + + assertThat(countAfterDestroy).isEqualTo(1) + assertThat(callCount.get()).isEqualTo(countAfterDestroy) + } + + @Test + fun `GIVEN multiple scheduleTask calls WHEN resumeLastTask THEN only the latest task is resumed`() = runTest { + val firstCount = AtomicInteger(0) + val secondCount = AtomicInteger(0) + val firstTask = PeriodicTask( + delay = PERIOD, + task = { firstCount.incrementAndGet(); Result.success(VALUE) }, + onSuccess = mockk(relaxed = true), + onError = mockk(relaxed = true), + initialDelay = 0L, + ) + val secondTask = PeriodicTask( + delay = PERIOD, + task = { secondCount.incrementAndGet(); Result.success(VALUE) }, + onSuccess = mockk(relaxed = true), + onError = mockk(relaxed = true), + initialDelay = 0L, + ) + val scheduler = SingleTaskScheduler() + scheduler.scheduleTask(backgroundScope, firstTask) + runCurrent() + // scheduleTask cancels the previous task and overwrites lastTask. + scheduler.scheduleTask(backgroundScope, secondTask) + runCurrent() + scheduler.cancelTask() + advanceUntilIdle() + val firstAtPause = firstCount.get() + val secondAtPause = secondCount.get() + + scheduler.resumeLastTask(backgroundScope) + runCurrent() + + assertThat(firstCount.get()).isEqualTo(firstAtPause) + assertThat(secondCount.get()).isEqualTo(secondAtPause + 1) + scheduler.cancelTask() + } + private companion object { const val PERIOD = 10_000L const val INITIAL_DELAY = 1_000L diff --git a/domain/transaction/src/main/java/com/tangem/domain/transaction/usecase/GetFeeUseCase.kt b/domain/transaction/src/main/java/com/tangem/domain/transaction/usecase/GetFeeUseCase.kt index b78313fd30..e4f0234272 100644 --- a/domain/transaction/src/main/java/com/tangem/domain/transaction/usecase/GetFeeUseCase.kt +++ b/domain/transaction/src/main/java/com/tangem/domain/transaction/usecase/GetFeeUseCase.kt @@ -7,14 +7,14 @@ import com.tangem.blockchain.common.AmountType import com.tangem.blockchain.common.Token import com.tangem.blockchain.common.TransactionData import com.tangem.blockchain.extensions.Result -import com.tangem.domain.demo.models.DemoConfig import com.tangem.domain.demo.DemoTransactionSender +import com.tangem.domain.demo.models.DemoConfig import com.tangem.domain.models.currency.CryptoCurrency import com.tangem.domain.models.network.Network +import com.tangem.domain.models.wallet.UserWallet import com.tangem.domain.transaction.error.GetFeeError import com.tangem.domain.transaction.error.mapToFeeError import com.tangem.domain.walletmanager.WalletManagersFacade -import com.tangem.domain.models.wallet.UserWallet import java.math.BigDecimal /** diff --git a/features/swap/CLAUDE.md b/features/swap/CLAUDE.md index c4f75d64f1..dfb670e330 100644 --- a/features/swap/CLAUDE.md +++ b/features/swap/CLAUDE.md @@ -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` for approval bottom sheet (`GiveApprovalComponent`) -- `SlotNavigation` for fee selector block +- `childStack(SwapRoute)` — `SwapRoute.Main`, `SwapRoute.Success`, `SwapRoute.SelectToken(isFromDirection)`, rendered via `Children` with fade animation +- `SlotNavigation` — approval bottom sheet (`GiveApprovalComponent`) +- `SlotNavigation` — 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` — 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` — stack navigation exposed from `SwapRouter` - `approvalSlotNavigation: SlotNavigation` — approval bottom sheet -**Navigation:** -- `SwapRouter` wraps `AppRouter` + `StackNavigation` 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`, `selectedProvider`, `lastLoadedSwapStates: Map`, `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`. 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`. `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>` -- `findBestQuote(from, to, providers, amount, ...)` → `Map` -- `onSwap(from, to, provider, swapData, amount, fee, ...)` → `SwapTransactionState` -- `loadFeeForSwapTransaction(...)` → `Either` -- `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>` +- `findProvidersForPair(from, to, pairs)` → `List` +- `findProvidersForPairWithCheck(from, to, pairs)` → `List` (checks asset requirements/FCA) +- `findBestQuote(from, to, providers, amount, reduceBalanceBy)` → `Map` (parallel per-provider) +- `onSwap(from, to, provider, swapData, amount, includeFeeInAmount, fee, operationType, isTangemPayWithdrawal)` → `SwapTransactionState` +- `loadSwapFee(provider, fromStatus, toStatus, amount, swapData, selectedFeeToken)` → `Either` — 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` +- `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 -``` \ No newline at end of file +- `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. \ No newline at end of file diff --git a/features/swap/data/src/main/java/com/tangem/feature/swap/DefaultSwapRepository.kt b/features/swap/data/src/main/java/com/tangem/feature/swap/DefaultSwapRepository.kt index f30d3ef92f..3cfbf7fa8f 100644 --- a/features/swap/data/src/main/java/com/tangem/feature/swap/DefaultSwapRepository.kt +++ b/features/swap/data/src/main/java/com/tangem/feature/swap/DefaultSwapRepository.kt @@ -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() } } diff --git a/features/swap/data/src/main/java/com/tangem/feature/swap/converters/ErrorsDataConverter.kt b/features/swap/data/src/main/java/com/tangem/feature/swap/converters/ErrorsDataConverter.kt index 2df4aa2f11..c900121c9f 100644 --- a/features/swap/data/src/main/java/com/tangem/feature/swap/converters/ErrorsDataConverter.kt +++ b/features/swap/data/src/main/java/com/tangem/feature/swap/converters/ErrorsDataConverter.kt @@ -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() } } diff --git a/features/swap/domain/src/main/java/com/tangem/feature/swap/domain/SwapInteractor.kt b/features/swap/domain/src/main/java/com/tangem/feature/swap/domain/SwapInteractor.kt index 98d5c4d9e1..7f71623656 100644 --- a/features/swap/domain/src/main/java/com/tangem/feature/swap/domain/SwapInteractor.kt +++ b/features/swap/domain/src/main/java/com/tangem/feature/swap/domain/SwapInteractor.kt @@ -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, ): List - @Suppress("LongParameterList") @Throws(IllegalStateException::class) suspend fun findBestQuote( fromSwapCurrencyStatus: SwapCurrencyStatus, @@ -44,9 +40,19 @@ interface SwapInteractor { providers: List, amountToSwap: String, reduceBalanceBy: BigDecimal, - txFeeSealedState: TxFeeSealedState, ): Map + /** + * 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 - - suspend fun loadFeeForSwapTransaction( - fromSwapCurrencyStatus: SwapCurrencyStatus, - toSwapCurrencyStatus: SwapCurrencyStatus, - amount: String, - reduceBalanceBy: BigDecimal, - provider: SwapProvider, - ): Either + ): Either } \ No newline at end of file diff --git a/features/swap/domain/src/main/java/com/tangem/feature/swap/domain/SwapInteractorImpl.kt b/features/swap/domain/src/main/java/com/tangem/feature/swap/domain/SwapInteractorImpl.kt index e3cbf8b44f..b1b03a0bb9 100644 --- a/features/swap/domain/src/main/java/com/tangem/feature/swap/domain/SwapInteractorImpl.kt +++ b/features/swap/domain/src/main/java/com/tangem/feature/swap/domain/SwapInteractorImpl.kt @@ -3,15 +3,15 @@ package com.tangem.feature.swap.domain import android.util.Base64 import arrow.core.Either import arrow.core.getOrElse +import arrow.core.left import arrow.core.raise.either +import arrow.core.right import com.tangem.blockchain.blockchains.ethereum.EthereumTransactionExtras -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.blockchain.yieldsupply.providers.ethereum.yield.EthereumYieldSupplySendCallData import com.tangem.blockchainsdk.utils.fromNetworkId import com.tangem.blockchainsdk.utils.toBlockchain @@ -40,38 +40,34 @@ import com.tangem.domain.swap.models.SwapTxType import com.tangem.domain.swap.usecase.GetSwapPairUseCase import com.tangem.domain.tokens.GetAssetRequirementsUseCase import com.tangem.domain.tokens.GetCurrencyCheckUseCase -import com.tangem.domain.tokens.MultiWalletCryptoCurrenciesProducer -import com.tangem.domain.tokens.MultiWalletCryptoCurrenciesSupplier import com.tangem.domain.tokens.model.FeePaidCurrency import com.tangem.domain.tokens.model.warnings.CryptoCurrencyCheck import com.tangem.domain.tokens.repository.CurrenciesRepository import com.tangem.domain.tokens.repository.CurrencyChecksRepository import com.tangem.domain.transaction.error.GetFeeError import com.tangem.domain.transaction.models.AllowanceInfo -import com.tangem.domain.transaction.models.TransactionFeeExtended 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.utils.convertToSdkAmount 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.SwapFeeFactory +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.* import com.tangem.feature.swap.domain.models.toStringWithRightOffset import com.tangem.feature.swap.domain.models.ui.* -import com.tangem.lib.crypto.BlockchainUtils.SOLANA_TRANSACTION_SIZE_THRESHOLD_BYTES import com.tangem.utils.coroutines.runSuspendCatching +import com.tangem.utils.extensions.orZero import com.tangem.utils.logging.TangemLogger import jakarta.inject.Inject import kotlinx.coroutines.async import kotlinx.coroutines.awaitAll -import kotlinx.coroutines.coroutineScope import kotlinx.coroutines.supervisorScope import java.math.BigDecimal -import java.math.BigInteger import java.math.RoundingMode @Suppress("LargeClass", "LongParameterList") @@ -90,15 +86,8 @@ internal class SwapInteractorImpl @Inject constructor( private val currencyChecksRepository: CurrencyChecksRepository, private val appCurrencyRepository: AppCurrencyRepository, private val currenciesRepository: CurrenciesRepository, - private val multiWalletCryptoCurrenciesSupplier: MultiWalletCryptoCurrenciesSupplier, private val validateTransactionUseCase: ValidateTransactionUseCase, - private val estimateFeeUseCase: EstimateFeeUseCase, - private val estimateFeeForTokenUseCase: EstimateFeeForTokenUseCase, - private val estimateFeeForGaslessTxUseCase: EstimateFeeForGaslessTxUseCase, - private val getFeeForTokenUseCase: GetFeeForTokenUseCase, private val createAndSendGaslessTransactionUseCase: CreateAndSendGaslessTransactionUseCase, - private val getFeeUseCase: GetFeeUseCase, - private val getEthSpecificFeeUseCase: GetEthSpecificFeeUseCase, private val getCurrencyCheckUseCase: GetCurrencyCheckUseCase, private val getAssetRequirementsUseCase: GetAssetRequirementsUseCase, private val amountFormatter: AmountFormatter, @@ -107,14 +96,14 @@ internal class SwapInteractorImpl @Inject constructor( private val walletManagersFacade: WalletManagersFacade, private val getAllowanceInfoUseCase: GetAllowanceInfoUseCase, private val getSwapPairUseCase: GetSwapPairUseCase, + private val dexSwapFeeCalculator: DexSwapFeeCalculator, + private val cexSwapFeeCalculator: CexSwapFeeCalculator, ) : SwapInteractor { private val getSelectedAppCurrencyUseCase by lazy(LazyThreadSafetyMode.NONE) { GetSelectedAppCurrencyUseCase(appCurrencyRepository) } - private val hundredPercent = BigInteger("100") - override suspend fun getPair( fromSwapCurrencyStatus: SwapCurrencyStatus, toSwapCurrencyStatus: SwapCurrencyStatus, @@ -191,12 +180,11 @@ internal class SwapInteractorImpl @Inject constructor( providers: List, amountToSwap: String, reduceBalanceBy: BigDecimal, - txFeeSealedState: TxFeeSealedState, ): Map { TangemLogger.i( """ Find the best quote - |- fromSwapCurrencyStatus: + |- fromSwapCurrencyStatus: |---- walletId: ${fromSwapCurrencyStatus.userWalletId} |---- accountId: ${fromSwapCurrencyStatus.account.accountId} |---- currencyId: ${fromSwapCurrencyStatus.currency.id} @@ -206,7 +194,6 @@ internal class SwapInteractorImpl @Inject constructor( |---- currencyId: ${toSwapCurrencyStatus.currency.id} |- providers: $providers |- amountToSwap: $amountToSwap - |- selectedFee: $txFeeSealedState """.trimIndent(), shouldSanitize = false, ) @@ -216,7 +203,7 @@ internal class SwapInteractorImpl @Inject constructor( return providers.associateWith { createEmptyAmountState() } } val amount = SwapAmount(amountDecimal, fromSwapCurrencyStatus.currency.decimals) - val isBalanceWithoutFeeEnough = isBalanceEnough(fromSwapCurrencyStatus, amount, null) + return supervisorScope { providers.map { provider -> async { @@ -227,9 +214,7 @@ internal class SwapInteractorImpl @Inject constructor( fromSwapCurrencyStatus = fromSwapCurrencyStatus, toSwapCurrencyStatus = toSwapCurrencyStatus, provider = provider, - txFeeSealedState = txFeeSealedState, amount = amount, - isBalanceWithoutFeeEnough = isBalanceWithoutFeeEnough, expressOperationType = ExpressOperationType.SWAP, ) } else { @@ -237,9 +222,7 @@ internal class SwapInteractorImpl @Inject constructor( fromSwapCurrencyStatus = fromSwapCurrencyStatus, toSwapCurrencyStatus = toSwapCurrencyStatus, provider = provider, - txFeeSealedState = txFeeSealedState, amount = amount, - isBalanceWithoutFeeEnough = isBalanceWithoutFeeEnough, expressOperationType = ExpressOperationType.SWAP, ) } @@ -251,8 +234,6 @@ internal class SwapInteractorImpl @Inject constructor( provider = provider, amount = amount, reduceBalanceBy = reduceBalanceBy, - isBalanceWithoutFeeEnough = isBalanceWithoutFeeEnough, - txFeeSealedState = txFeeSealedState, ) } } @@ -266,14 +247,12 @@ internal class SwapInteractorImpl @Inject constructor( fromSwapCurrencyStatus: SwapCurrencyStatus, toSwapCurrencyStatus: SwapCurrencyStatus, provider: SwapProvider, - txFeeSealedState: TxFeeSealedState, amount: SwapAmount, - isBalanceWithoutFeeEnough: Boolean, expressOperationType: ExpressOperationType, ): Pair { if (fromSwapCurrencyStatus.status.value.yieldSupplyStatus?.isActive == true) { return provider to produceDexSwapDataError( - error = ExpressDataError.DexActiveSupplyError, + error = ExpressDataError.DexActiveSupplyError(), fromSwapCurrencyStatus = fromSwapCurrencyStatus, amount = amount, ) @@ -314,16 +293,21 @@ internal class SwapInteractorImpl @Inject constructor( currency = fromSwapCurrencyStatus.currency, ) } + val isBalanceWithoutFeeEnough = isBalanceEnough(fromSwapCurrencyStatus, amount, null) return if (isAllowedToSpend && isBalanceWithoutFeeEnough) { - provider to loadDexSwapData( + provider to loadDexSwapDataNoFee( provider = provider, fromSwapCurrencyStatus = fromSwapCurrencyStatus, toSwapCurrencyStatus = toSwapCurrencyStatus, amount = amount, - txFeeSealedState = txFeeSealedState, expressOperationType = expressOperationType, ) } else { + val quoteBalanceStatus = if (isBalanceWithoutFeeEnough) { + SwapBalanceStatus.Pending // fee not resolved yet + } else { + SwapBalanceStatus.InsufficientAmount + } provider to getQuotesState( provider = provider, quoteDataModel = maybeQuotes, @@ -331,9 +315,7 @@ internal class SwapInteractorImpl @Inject constructor( fromSwapCurrencyStatus = fromSwapCurrencyStatus, toSwapCurrencyStatus = toSwapCurrencyStatus, isAllowedToSpend = isAllowedToSpend, - isBalanceWithoutFeeEnough = isBalanceWithoutFeeEnough, - txFeeSealedState = txFeeSealedState, - includeFeeInAmount = IncludeFeeInAmount.Excluded, // exclude for dex + quoteBalanceStatus = quoteBalanceStatus, ) } } @@ -342,9 +324,7 @@ internal class SwapInteractorImpl @Inject constructor( fromSwapCurrencyStatus: SwapCurrencyStatus, toSwapCurrencyStatus: SwapCurrencyStatus, provider: SwapProvider, - txFeeSealedState: TxFeeSealedState, amount: SwapAmount, - isBalanceWithoutFeeEnough: Boolean, expressOperationType: ExpressOperationType, ): Pair { val maybeQuotes = repository.findBestQuote( @@ -359,14 +339,17 @@ internal class SwapInteractorImpl @Inject constructor( providerId = provider.providerId, rateType = RateType.FLOAT, ) - - return if (isBalanceWithoutFeeEnough && maybeQuotes.isRight()) { - provider to loadDexSwapData( + val quoteBalanceStatus = if (isBalanceEnough(fromSwapCurrencyStatus, amount, null)) { + SwapBalanceStatus.Pending // fee not resolved yet + } else { + SwapBalanceStatus.InsufficientAmount + } + return if (quoteBalanceStatus != SwapBalanceStatus.InsufficientAmount && maybeQuotes.isRight()) { + provider to loadDexSwapDataNoFee( provider = provider, fromSwapCurrencyStatus = fromSwapCurrencyStatus, toSwapCurrencyStatus = toSwapCurrencyStatus, amount = amount, - txFeeSealedState = txFeeSealedState, expressOperationType = expressOperationType, ) } else { @@ -377,9 +360,7 @@ internal class SwapInteractorImpl @Inject constructor( fromSwapCurrencyStatus = fromSwapCurrencyStatus, toSwapCurrencyStatus = toSwapCurrencyStatus, isAllowedToSpend = true, - isBalanceWithoutFeeEnough = false, - txFeeSealedState = txFeeSealedState, - includeFeeInAmount = IncludeFeeInAmount.Excluded, // exclude for dex + quoteBalanceStatus = quoteBalanceStatus, ) } } @@ -390,56 +371,66 @@ internal class SwapInteractorImpl @Inject constructor( provider: SwapProvider, amount: SwapAmount, reduceBalanceBy: BigDecimal, - isBalanceWithoutFeeEnough: Boolean, - txFeeSealedState: TxFeeSealedState, ): Pair { - return provider to loadCexQuoteData( + val fromToken = fromSwapCurrencyStatus.currency + val toToken = toSwapCurrencyStatus.currency + + val includeFeeInAmount = getIncludeFeeInAmountInternal( + fromSwapCurrencyStatus = fromSwapCurrencyStatus, amount = amount, reduceBalanceBy = reduceBalanceBy, + feeValue = BigDecimal.ZERO, + ) + + val amountToRequest = if (includeFeeInAmount is IncludeFeeInAmountInternal.Included) { + includeFeeInAmount.amountSubtractFee + } else { + amount + } + + val quotes = repository.findBestQuote( + userWallet = fromSwapCurrencyStatus.userWallet, + fromContractAddress = fromToken.getContractAddress(), + fromNetwork = fromToken.network.rawId, + toContractAddress = toToken.getContractAddress(), + toNetwork = toToken.network.rawId, + fromAmount = amountToRequest.toStringWithRightOffset(), + fromDecimals = amount.decimals, + toDecimals = toToken.decimals, + providerId = provider.providerId, + rateType = RateType.FLOAT, + ) + + val quoteBalanceStatus = if (includeFeeInAmount == IncludeFeeInAmountInternal.BalanceNotEnough) { + SwapBalanceStatus.InsufficientAmount + } else { + SwapBalanceStatus.Pending // fee not resolved yet + } + + return provider to getQuotesState( + provider = provider, + quoteDataModel = quotes, + amount = amount, fromSwapCurrencyStatus = fromSwapCurrencyStatus, toSwapCurrencyStatus = toSwapCurrencyStatus, isAllowedToSpend = true, - isBalanceWithoutFeeEnough = isBalanceWithoutFeeEnough, - provider = provider, - txFeeSealedState = txFeeSealedState, + quoteBalanceStatus = quoteBalanceStatus, ) } private suspend fun manageWarnings( fromSwapCurrencyStatus: SwapCurrencyStatus, amount: SwapAmount, - txFeeSealed: TxFeeSealedState?, - includeFeeInAmount: IncludeFeeInAmount, + fee: BigDecimal, + balanceStatus: SwapBalanceStatus, ): CryptoCurrencyCheck { - val fee = when (txFeeSealed) { - is TxFeeSealedState.Component -> { - if (txFeeSealed.txFee.selectedToken?.currency is CryptoCurrency.Token) { - BigDecimal.ZERO - } else { - txFeeSealed.txFee.fee.amount.value - } - } - is TxFeeSealedState.Legacy -> { - when (val feeState = txFeeSealed.txFeeState) { - TxFeeState.Empty -> BigDecimal.ZERO - is TxFeeState.MultipleFeeState -> feeState.getFeeByType(txFeeSealed.selectedFee).fee.amount.value - is TxFeeState.SingleFeeState -> feeState.fee.fee.amount.value - } - } - null -> BigDecimal.ZERO - } ?: BigDecimal.ZERO - val balanceAfterTransaction = getCoinBalanceAfterTransaction( fromSwapCurrencyStatus = fromSwapCurrencyStatus, amount = amount, - includeFeeInAmount = includeFeeInAmount, + balanceStatus = balanceStatus, fee = fee, ) - val amountToRequest = if (includeFeeInAmount is IncludeFeeInAmount.Included) { - includeFeeInAmount.amountSubtractFee - } else { - amount - } + val amountToRequest = (balanceStatus as? SwapBalanceStatus.FeeAdjustedAmount)?.adjustedAmount ?: amount val feePaidCurrencyStatus = getFeePaidCryptoCurrencyStatusSyncUseCase( userWalletId = fromSwapCurrencyStatus.userWalletId, cryptoCurrencyStatus = fromSwapCurrencyStatus.status, @@ -456,23 +447,30 @@ internal class SwapInteractorImpl @Inject constructor( return currencyCheck } + /** + * - `FeeAdjustedAmount` → equivalent to `Included(adjusted)`: subtract adjusted + fee + * - `Sufficient` / `InsufficientFee` → equivalent to `Excluded`: subtract amount + fee + * - `InsufficientAmount` / `Pending` → returns null + */ private suspend fun getCoinBalanceAfterTransaction( fromSwapCurrencyStatus: SwapCurrencyStatus, amount: SwapAmount, - includeFeeInAmount: IncludeFeeInAmount, + balanceStatus: SwapBalanceStatus, fee: BigDecimal, ): BigDecimal? { return when (fromSwapCurrencyStatus.currency) { is CryptoCurrency.Coin -> { - val statusValue = fromSwapCurrencyStatus.status.value as? CryptoCurrencyStatus.Loaded - when (includeFeeInAmount) { - is IncludeFeeInAmount.Included -> { - statusValue?.let { it.amount - includeFeeInAmount.amountSubtractFee.value - fee } + val statusValue = fromSwapCurrencyStatus.status.value as? CryptoCurrencyStatus.Loaded ?: return null + when (balanceStatus) { + is SwapBalanceStatus.FeeAdjustedAmount -> { + statusValue.amount - balanceStatus.adjustedAmount.value - fee } - is IncludeFeeInAmount.Excluded -> { - statusValue?.let { it.amount - amount.value - fee } - } - else -> null + is SwapBalanceStatus.Sufficient, + is SwapBalanceStatus.InsufficientFee, + -> statusValue.amount - amount.value - fee + is SwapBalanceStatus.InsufficientAmount, + is SwapBalanceStatus.Pending, + -> null } } is CryptoCurrency.Token -> { @@ -487,7 +485,7 @@ internal class SwapInteractorImpl @Inject constructor( nativeBalance - fee } - else -> null // it doesnt matter for this fun + else -> null // it doesn't matter for this fun } } } @@ -496,7 +494,7 @@ internal class SwapInteractorImpl @Inject constructor( private suspend fun manageTransactionValidationWarnings( fromSwapCurrencyStatus: SwapCurrencyStatus, amount: SwapAmount, - txFeeSealedState: TxFeeSealedState, + feeValue: BigDecimal, ): Throwable? { val currency = fromSwapCurrencyStatus.currency val blockchain = currency.network.toBlockchain() @@ -504,16 +502,6 @@ internal class SwapInteractorImpl @Inject constructor( if (blockchain == Blockchain.Stellar) { return null } - val feeValue = when (txFeeSealedState) { - is TxFeeSealedState.Component -> txFeeSealedState.txFee.fee.amount.value - is TxFeeSealedState.Legacy -> { - when (val feeState = txFeeSealedState.txFeeState) { - TxFeeState.Empty -> BigDecimal.ZERO - is TxFeeState.MultipleFeeState -> feeState.normalFee.fee.amount.value - is TxFeeState.SingleFeeState -> feeState.fee.fee.amount.value - } - } - } val fee = Fee.Common( amount = Amount( @@ -541,8 +529,8 @@ internal class SwapInteractorImpl @Inject constructor( swapProvider: SwapProvider, swapData: SwapDataModel?, amountToSwap: String, - includeFeeInAmount: IncludeFeeInAmount, - fee: TxFee?, + balanceStatus: SwapBalanceStatus, + fee: SwapFee?, expressOperationType: ExpressOperationType, isTangemPayWithdrawal: Boolean, ): SwapTransactionState { @@ -551,7 +539,7 @@ internal class SwapInteractorImpl @Inject constructor( Swap |- swapProvider: $swapProvider |- swapData: $swapData - |- fromSwapCurrencyStatus: + |- fromSwapCurrencyStatus: |---- walletId: ${fromSwapCurrencyStatus.userWalletId} |---- accountId: ${fromSwapCurrencyStatus.account.accountId} |---- currencyId: ${fromSwapCurrencyStatus.currency.id} @@ -560,7 +548,7 @@ internal class SwapInteractorImpl @Inject constructor( |---- accountId: ${toSwapCurrencyStatus.account.accountId} |---- currencyId: ${toSwapCurrencyStatus.currency.id} |- amountToSwap: $amountToSwap - |- includeFeeInAmount: $includeFeeInAmount + |- balanceStatus: $balanceStatus |- fee: $fee """.trimIndent(), shouldSanitize = false, @@ -575,16 +563,13 @@ internal class SwapInteractorImpl @Inject constructor( ExchangeProviderType.CEX -> { val amountDecimal = toBigDecimalOrNull(amountToSwap) val amount = SwapAmount(requireNotNull(amountDecimal), fromSwapCurrencyStatus.currency.decimals) - val amountToSwapWithFee = if (includeFeeInAmount is IncludeFeeInAmount.Included) { - includeFeeInAmount.amountSubtractFee - } else { - amount - } + val amountToSwapWithFee = (balanceStatus as? SwapBalanceStatus.FeeAdjustedAmount)?.adjustedAmount + ?: amount onSwapCex( fromSwapCurrencyStatus = fromSwapCurrencyStatus, toSwapCurrencyStatus = toSwapCurrencyStatus, amount = amountToSwapWithFee, - txFee = fee, + swapFee = fee, swapProvider = swapProvider, expressOperationType = expressOperationType, isTangemPayWithdrawal = isTangemPayWithdrawal, @@ -607,7 +592,7 @@ internal class SwapInteractorImpl @Inject constructor( swapData = requireNotNull(swapData), fromSwapCurrencyStatus = fromSwapCurrencyStatus, toSwapCurrencyStatus = toSwapCurrencyStatus, - txFee = fee, + swapFee = fee, amountToSwap = amountToSwap, ) } @@ -621,7 +606,7 @@ internal class SwapInteractorImpl @Inject constructor( provider: SwapProvider, swapData: SwapDataModel, amountToSwap: String, - txFee: TxFee, + swapFee: SwapFee, ): SwapTransactionState { val amountDecimal = requireNotNull(toBigDecimalOrNull(amountToSwap)) { "wrong amount format" } val txValue = requireNotNull(swapData.transaction.txValue) { "txValue is null" } @@ -631,7 +616,7 @@ internal class SwapInteractorImpl @Inject constructor( val amountToSend = createNativeAmountForDex(txValue, fromSwapCurrencyStatus.currency.network) val txData = createTransactionUseCase( amount = amountToSend, - fee = txFee.fee, + fee = swapFee.fee, memo = null, destination = swapData.transaction.txTo, userWalletId = fromSwapCurrencyStatus.userWalletId, @@ -639,7 +624,7 @@ internal class SwapInteractorImpl @Inject constructor( txExtras = createDexTxExtras( dataToSign, fromSwapCurrencyStatus.currency.network, - txFee.fee.getGasLimit(), + swapFee.fee.getGasLimit(), ), ).getOrElse { error -> TangemLogger.e("Failed to create swap dex tx data", error) @@ -657,6 +642,165 @@ internal class SwapInteractorImpl @Inject constructor( ) } + /** + * Branch selection: + * - Gasless token path: `swapFee.transactionFeeResult is LoadedExtended && selectedFeeToken.currency is Token` + * → `createAndSendGaslessTransactionUseCase`. + * - Otherwise → `sendTransactionUseCase` with `swapFee.fee`. + */ + @Suppress("LongMethod", "CanBeNonNullable") + private suspend fun onSwapCex( + fromSwapCurrencyStatus: SwapCurrencyStatus, + toSwapCurrencyStatus: SwapCurrencyStatus, + amount: SwapAmount, + swapFee: SwapFee?, + swapProvider: SwapProvider, + expressOperationType: ExpressOperationType, + isTangemPayWithdrawal: Boolean, + ): SwapTransactionState { + val fromNetworkAddress = fromSwapCurrencyStatus.status.value.networkAddress + val fromAddress = fromNetworkAddress?.defaultAddress?.value.orEmpty() + val toNetworkAddress = toSwapCurrencyStatus.status.value.networkAddress + val toAddress = toNetworkAddress?.defaultAddress?.value.orEmpty() + val exchangeData = repository.getExchangeData( + userWallet = fromSwapCurrencyStatus.userWallet, + fromContractAddress = fromSwapCurrencyStatus.currency.getContractAddress(), + fromNetwork = fromSwapCurrencyStatus.currency.network.rawId, + toContractAddress = toSwapCurrencyStatus.currency.getContractAddress(), + fromAddress = fromAddress, + toNetwork = toSwapCurrencyStatus.currency.network.rawId, + fromAmount = amount.toStringWithRightOffset(), + fromDecimals = amount.decimals, + toDecimals = toSwapCurrencyStatus.currency.decimals, + providerId = swapProvider.providerId, + rateType = RateType.FLOAT, + expressOperationType = expressOperationType, + toAddress = toAddress, + refundAddress = fromNetworkAddress?.defaultAddress?.value, + refundExtraId = null, // currently always null, + ).getOrElse { error -> return SwapTransactionState.Error.ExpressError(error) } + + val exchangeDataCex = + exchangeData.transaction as? ExpressTransactionModel.CEX ?: return SwapTransactionState.Error.UnknownError + + if (isTangemPayWithdrawal) { + return SwapTransactionState.TangemPayWithdrawalData( + cryptoAmount = amount.value, + cryptoCurrencyId = requireNotNull(fromSwapCurrencyStatus.currency.id.rawCurrencyId), + cexAddress = exchangeDataCex.txTo, + fromAmount = amountFormatter.formatSwapAmountToUI( + amount, + fromSwapCurrencyStatus.currency.symbol, + ), + fromAmountValue = amount.value, + toAmount = amountFormatter.formatSwapAmountToUI( + exchangeData.toTokenAmount, + toSwapCurrencyStatus.currency.symbol, + ), + toAmountValue = exchangeData.toTokenAmount.value, + storeData = SwapTransactionState.TangemPayWithdrawalData.StoreTransactionData( + fromSwapCurrencyStatus = fromSwapCurrencyStatus, + toSwapCurrencyStatus = toSwapCurrencyStatus, + amount = amount, + swapProvider = swapProvider, + swapDataModel = exchangeData, + txExternalUrl = exchangeDataCex.externalTxUrl, + txExternalId = exchangeDataCex.externalTxId, + averageDuration = null, + ), + exchangeData = TangemPayWithdrawExchangeState( + txId = exchangeDataCex.txId, + fromNetwork = fromSwapCurrencyStatus.currency.network.rawId, + fromAddress = fromNetworkAddress?.defaultAddress?.value.orEmpty(), + payInAddress = exchangeData.transaction.txTo, + payInExtraId = exchangeDataCex.txExtraId, + ), + ) + } + + val userWallet = fromSwapCurrencyStatus.userWallet + if (userWallet is UserWallet.Cold && isDemoCardUseCase(userWallet.scanResponse.card.cardId)) { + return SwapTransactionState.Error.UnknownError + } + val fee = requireNotNull(swapFee) + val txData = createTransferTransactionUseCase( + amount = amount.value.convertToSdkAmount(fromSwapCurrencyStatus.status), + fee = fee.fee, + memo = exchangeDataCex.txExtraId, + destination = exchangeDataCex.txTo, + userWalletId = fromSwapCurrencyStatus.userWalletId, + network = fromSwapCurrencyStatus.currency.network, + ).getOrElse { error -> + TangemLogger.e("Failed to create swap CEX tx data", error) + return SwapTransactionState.Error.UnknownError + } + + if (txData.extras == null && exchangeDataCex.txExtraId != null) { + return SwapTransactionState.Error.UnknownError + } + + val isGaslessToken = fee.selectedFeeToken.currency is CryptoCurrency.Token && + fee.transactionFeeResult is TransactionFeeResult.LoadedExtended + val result = if (isGaslessToken) { + createAndSendGaslessTransactionUseCase.invoke( + transactionData = txData, + userWallet = userWallet, + fee = fee.transactionFeeResult.fee, + ) + } else { + sendTransactionUseCase( + txData = txData, + userWallet = userWallet, + network = fromSwapCurrencyStatus.currency.network, + ) + } + + val cexNetworkAddress = fromSwapCurrencyStatus.status.value.networkAddress + val cexFromAddress = cexNetworkAddress?.defaultAddress?.value.orEmpty() + return result.fold( + ifLeft = { error -> SwapTransactionState.Error.TransactionError(error) }, + ifRight = { txHash -> + repository.exchangeSent( + userWallet = userWallet, + txId = exchangeDataCex.txId, + fromNetwork = fromSwapCurrencyStatus.currency.network.rawId, + fromAddress = cexFromAddress, + payInAddress = getPayoutAddress(txData), + txHash = txHash, + payInExtraId = exchangeDataCex.txExtraId, + ) + val timestamp = System.currentTimeMillis() + val txExternalUrl = exchangeDataCex.externalTxUrl + storeSwapTransaction( + fromSwapCurrencyStatus = fromSwapCurrencyStatus, + toSwapCurrencyStatus = toSwapCurrencyStatus, + amount = amount, + swapProvider = swapProvider, + swapDataModel = exchangeData, + timestamp = timestamp, + txExternalUrl = txExternalUrl, + txExternalId = exchangeDataCex.externalTxId, + ) + storeLastCryptoCurrencyId(toSwapCurrencyStatus) + SwapTransactionState.TxSent( + fromAmount = amountFormatter.formatSwapAmountToUI( + amount, + fromSwapCurrencyStatus.currency.symbol, + ), + fromAmountValue = amount.value, + toAmount = amountFormatter.formatSwapAmountToUI( + exchangeData.toTokenAmount, + toSwapCurrencyStatus.currency.symbol, + ), + toAmountValue = exchangeData.toTokenAmount.value, + txHash = txHash, + txExternalUrl = txExternalUrl, + timestamp = timestamp, + ) + }, + ) + } + private suspend fun onSwapSolanaDex( provider: SwapProvider, swapData: SwapDataModel, @@ -746,170 +890,6 @@ internal class SwapInteractorImpl @Inject constructor( ).getOrNull() ?: error("failed to create extras") } - @Suppress("LongMethod", "CanBeNonNullable") - private suspend fun onSwapCex( - fromSwapCurrencyStatus: SwapCurrencyStatus, - toSwapCurrencyStatus: SwapCurrencyStatus, - amount: SwapAmount, - txFee: TxFee?, - swapProvider: SwapProvider, - expressOperationType: ExpressOperationType, - isTangemPayWithdrawal: Boolean, - ): SwapTransactionState { - val fromNetworkAddress = fromSwapCurrencyStatus.status.value.networkAddress - val fromAddress = fromNetworkAddress?.defaultAddress?.value.orEmpty() - val toNetworkAddress = toSwapCurrencyStatus.status.value.networkAddress - val toAddress = toNetworkAddress?.defaultAddress?.value.orEmpty() - val exchangeData = repository.getExchangeData( - userWallet = fromSwapCurrencyStatus.userWallet, - fromContractAddress = fromSwapCurrencyStatus.currency.getContractAddress(), - fromNetwork = fromSwapCurrencyStatus.currency.network.rawId, - toContractAddress = toSwapCurrencyStatus.currency.getContractAddress(), - fromAddress = fromAddress, - toNetwork = toSwapCurrencyStatus.currency.network.rawId, - fromAmount = amount.toStringWithRightOffset(), - fromDecimals = amount.decimals, - toDecimals = toSwapCurrencyStatus.currency.decimals, - providerId = swapProvider.providerId, - rateType = RateType.FLOAT, - expressOperationType = expressOperationType, - toAddress = toAddress, - refundAddress = fromNetworkAddress?.defaultAddress?.value, - refundExtraId = null, // currently always null, - ).getOrElse { error -> return SwapTransactionState.Error.ExpressError(error) } - - val exchangeDataCex = - exchangeData.transaction as? ExpressTransactionModel.CEX ?: return SwapTransactionState.Error.UnknownError - - if (isTangemPayWithdrawal) { - return SwapTransactionState.TangemPayWithdrawalData( - cryptoAmount = amount.value, - cryptoCurrencyId = requireNotNull(fromSwapCurrencyStatus.currency.id.rawCurrencyId), - cexAddress = exchangeDataCex.txTo, - fromAmount = amountFormatter.formatSwapAmountToUI( - amount, - fromSwapCurrencyStatus.currency.symbol, - ), - fromAmountValue = amount.value, - toAmount = amountFormatter.formatSwapAmountToUI( - exchangeData.toTokenAmount, - toSwapCurrencyStatus.currency.symbol, - ), - toAmountValue = exchangeData.toTokenAmount.value, - storeData = SwapTransactionState.TangemPayWithdrawalData.StoreTransactionData( - fromSwapCurrencyStatus = fromSwapCurrencyStatus, - toSwapCurrencyStatus = toSwapCurrencyStatus, - amount = amount, - swapProvider = swapProvider, - swapDataModel = exchangeData, - txExternalUrl = exchangeDataCex.externalTxUrl, - txExternalId = exchangeDataCex.externalTxId, - averageDuration = null, - ), - exchangeData = TangemPayWithdrawExchangeState( - txId = exchangeDataCex.txId, - fromNetwork = fromSwapCurrencyStatus.currency.network.rawId, - fromAddress = fromNetworkAddress?.defaultAddress?.value.orEmpty(), - payInAddress = exchangeData.transaction.txTo, - payInExtraId = exchangeDataCex.txExtraId, - ), - ) - } - - val userWallet = fromSwapCurrencyStatus.userWallet - if (userWallet is UserWallet.Cold && isDemoCardUseCase(userWallet.scanResponse.card.cardId)) { - return SwapTransactionState.Error.UnknownError - } - val fee = requireNotNull(txFee) - val txData = createTransferTransactionUseCase( - amount = amount.value.convertToSdkAmount(fromSwapCurrencyStatus.status), - fee = fee.fee, - memo = exchangeDataCex.txExtraId, - destination = exchangeDataCex.txTo, - userWalletId = fromSwapCurrencyStatus.userWalletId, - network = fromSwapCurrencyStatus.currency.network, - ).getOrElse { error -> - TangemLogger.e("Failed to create swap CEX tx data", error) - return SwapTransactionState.Error.UnknownError - } - - if (txData.extras == null && exchangeDataCex.txExtraId != null) { - return SwapTransactionState.Error.UnknownError - } - - val result = when (fee) { - is TxFee.FeeComponent -> { - if (fee.selectedToken?.currency is CryptoCurrency.Token && - fee.transactionFeeResult is TransactionFeeResult.LoadedExtended - ) { - createAndSendGaslessTransactionUseCase.invoke( - transactionData = txData, - userWallet = userWallet, - fee = fee.transactionFeeResult.fee, - ) - } else { - sendTransactionUseCase( - txData = txData, - userWallet = userWallet, - network = fromSwapCurrencyStatus.currency.network, - ) - } - } - is TxFee.Legacy -> { - sendTransactionUseCase( - txData = txData, - userWallet = userWallet, - network = fromSwapCurrencyStatus.currency.network, - ) - } - } - - val cexNetworkAddress = fromSwapCurrencyStatus.status.value.networkAddress - val cexFromAddress = cexNetworkAddress?.defaultAddress?.value.orEmpty() - return result.fold( - ifLeft = { error -> SwapTransactionState.Error.TransactionError(error) }, - ifRight = { txHash -> - repository.exchangeSent( - userWallet = userWallet, - txId = exchangeDataCex.txId, - fromNetwork = fromSwapCurrencyStatus.currency.network.rawId, - fromAddress = cexFromAddress, - payInAddress = getPayoutAddress(txData), - txHash = txHash, - payInExtraId = exchangeDataCex.txExtraId, - ) - val timestamp = System.currentTimeMillis() - val txExternalUrl = exchangeDataCex.externalTxUrl - storeSwapTransaction( - fromSwapCurrencyStatus = fromSwapCurrencyStatus, - toSwapCurrencyStatus = toSwapCurrencyStatus, - amount = amount, - swapProvider = swapProvider, - swapDataModel = exchangeData, - timestamp = timestamp, - txExternalUrl = txExternalUrl, - txExternalId = exchangeDataCex.externalTxId, - ) - storeLastCryptoCurrencyId(toSwapCurrencyStatus) - SwapTransactionState.TxSent( - fromAmount = amountFormatter.formatSwapAmountToUI( - amount, - fromSwapCurrencyStatus.currency.symbol, - ), - fromAmountValue = amount.value, - toAmount = amountFormatter.formatSwapAmountToUI( - exchangeData.toTokenAmount, - toSwapCurrencyStatus.currency.symbol, - ), - toAmountValue = exchangeData.toTokenAmount.value, - txHash = txHash, - txExternalUrl = txExternalUrl, - timestamp = timestamp, - ) - }, - ) - } - override suspend fun storeSwapTransaction( fromSwapCurrencyStatus: SwapCurrencyStatus, toSwapCurrencyStatus: SwapCurrencyStatus, @@ -946,102 +926,296 @@ internal class SwapInteractorImpl @Inject constructor( ) } + /** + * Delegates to [DexSwapFeeCalculator] / [CexSwapFeeCalculator] and wraps the result in a [SwapFee]. + * The only fee load entry point used by the swap feature; + * + * See `SwapInteractor.loadSwapFee` for the full contract. + */ @Suppress("LongParameterList") - override suspend fun loadFeeForSwapTransaction( - fromSwapCurrencyStatus: SwapCurrencyStatus, - amount: String, - reduceBalanceBy: BigDecimal, + override suspend fun loadSwapFee( provider: SwapProvider, + fromStatus: SwapCurrencyStatus, + toStatus: SwapCurrencyStatus, + amount: SwapAmount, + swapData: SwapDataModel?, selectedFeeToken: CryptoCurrencyStatus?, - ): Either = either { - when (provider.type) { - ExchangeProviderType.DEX, - ExchangeProviderType.DEX_BRIDGE, - -> raise(GetFeeError.GaslessError.NetworkIsNotSupported) - ExchangeProviderType.CEX -> { - val amountDecimal = toBigDecimalOrNull(amount) - if (amountDecimal == null || amountDecimal.signum() == 0) { - raise(GetFeeError.UnknownError) - } - - return if (selectedFeeToken != null) { - estimateFeeForTokenUseCase( - userWallet = fromSwapCurrencyStatus.userWallet, - feeTokenCurrencyStatus = selectedFeeToken, - sendingTokenCurrencyStatus = fromSwapCurrencyStatus.status, - amount = amountDecimal, - ) - } else { - estimateFeeForGaslessTxUseCase( - amount = amountDecimal, - userWallet = fromSwapCurrencyStatus.userWallet, - sendingTokenCurrencyStatus = fromSwapCurrencyStatus.status, - ) - } - } + ): Either = either { + if (amount.value.signum() == 0) { + raise(GetFeeError.UnknownError) } - } - - override suspend fun loadFeeForSwapTransaction( - fromSwapCurrencyStatus: SwapCurrencyStatus, - toSwapCurrencyStatus: SwapCurrencyStatus, - amount: String, - reduceBalanceBy: BigDecimal, - provider: SwapProvider, - ): Either = either { return when (provider.type) { ExchangeProviderType.DEX, ExchangeProviderType.DEX_BRIDGE, - -> { - val fromNetworkAddress = fromSwapCurrencyStatus.status.value.networkAddress - val dexFromAddress = fromNetworkAddress?.defaultAddress?.value.orEmpty() - val toNetworkAddress = toSwapCurrencyStatus.status.value.networkAddress - val dexToAddress = toNetworkAddress?.defaultAddress?.value.orEmpty() - val amountBigDecimal = toBigDecimalOrNull(amount) - if (amountBigDecimal == null || amountBigDecimal.signum() == 0) { - raise(GetFeeError.UnknownError) - } - val swapAmount = SwapAmount(amountBigDecimal, fromSwapCurrencyStatus.currency.decimals) + -> loadDexSwapFee( + fromStatus = fromStatus, + swapData = swapData, + selectedFeeToken = selectedFeeToken, + ) + ExchangeProviderType.CEX -> loadCexSwapFee( + fromStatus = fromStatus, + amount = amount, + selectedFeeToken = selectedFeeToken, + ) + } + } - repository.getExchangeData( - userWallet = fromSwapCurrencyStatus.userWallet, - fromContractAddress = fromSwapCurrencyStatus.currency.getContractAddress(), - fromNetwork = fromSwapCurrencyStatus.currency.network.rawId, - toContractAddress = toSwapCurrencyStatus.currency.getContractAddress(), - fromAddress = dexFromAddress, - toNetwork = toSwapCurrencyStatus.currency.network.rawId, - fromAmount = swapAmount.toStringWithRightOffset(), - fromDecimals = swapAmount.decimals, - toDecimals = toSwapCurrencyStatus.currency.decimals, - providerId = provider.providerId, - rateType = RateType.FLOAT, - toAddress = dexToAddress, - refundAddress = fromNetworkAddress?.defaultAddress?.value, - expressOperationType = ExpressOperationType.SWAP, - ).map { swapData -> - val transaction = swapData.transaction as ExpressTransactionModel.DEX - loadFeeForDex( - fromSwapCurrencyStatus = fromSwapCurrencyStatus, - transaction = transaction, - ).getOrElse { raise(GetFeeError.UnknownError) } - }.mapLeft { - GetFeeError.UnknownError - } + /** + * [REDACTED_TASK_KEY] — DEX branch of [loadSwapFee]. Pulls the cached `ExpressTransactionModel.DEX` + * out of [swapData] and hands it to [DexSwapFeeCalculator]. Maps [ExpressDataError] → + * `Left(GetFeeError.UnknownError)` to keep the unified surface a single error type, matching + * what the legacy `loadFeeForSwapTransaction` overload 2 does for DEX failures (line 1027 of + * the original code). + */ + private suspend fun loadDexSwapFee( + fromStatus: SwapCurrencyStatus, + swapData: SwapDataModel?, + selectedFeeToken: CryptoCurrencyStatus?, + ): Either { + val transaction = swapData?.transaction as? ExpressTransactionModel.DEX + ?: return GetFeeError.UnknownError.left() + + return dexSwapFeeCalculator.calculate( + fromSwapCurrencyStatus = fromStatus, + transaction = transaction, + selectedToken = selectedFeeToken, + ).fold( + ifLeft = { error -> GetFeeError.DataError(error).left() }, + ifRight = { dexFeeResult -> + val feeToken = selectedFeeToken + ?: resolveNativeFeeTokenStatus(fromStatus) + ?: return@fold GetFeeError.UnknownError.left() + SwapFeeFactory.from( + transactionFeeResult = dexFeeResult.transactionFee, + selectedFeeToken = feeToken, + otherNativeFee = dexFeeResult.otherNativeFee, + feeBucket = FeeBucket.MARKET, + ).right() + }, + ) + } + + /** + * [REDACTED_TASK_KEY] — CEX branch of [loadSwapFee]. Native-fallback behavior is preserved: when + * [selectedFeeToken] is null the gasless use case (invoked inside [CexSwapFeeCalculator]) + * decides native vs token. The resulting `SwapFee.selectedFeeToken` is the explicit choice + * if provided, otherwise the native coin status of the from-token's network. + */ + private suspend fun loadCexSwapFee( + fromStatus: SwapCurrencyStatus, + amount: SwapAmount, + selectedFeeToken: CryptoCurrencyStatus?, + ): Either { + return cexSwapFeeCalculator.calculate( + userWallet = fromStatus.userWallet, + fromSwapCurrencyStatus = fromStatus, + amount = amount.value, + selectedFeeToken = selectedFeeToken, + ).fold( + ifLeft = { it.left() }, + ifRight = { cexFeeResult -> + val feeToken = selectedFeeToken + ?: resolveNativeFeeTokenStatus(fromStatus) + ?: return@fold GetFeeError.UnknownError.left() + SwapFeeFactory.from( + transactionFeeResult = cexFeeResult.transactionFee, + selectedFeeToken = feeToken, + otherNativeFee = BigDecimal.ZERO, + feeBucket = FeeBucket.MARKET, + ).right() + }, + ) + } + + /** + * [REDACTED_TASK_KEY] — resolves the native-coin [CryptoCurrencyStatus] for the from-token's network. + * Used as the default `selectedFeeToken` of [SwapFee] when the caller did not provide an + * explicit choice. Mirrors how `SwapModel.updateFeePaidCryptoCurrencyFor` populates + * `dataState.feePaidCryptoCurrency`. + */ + private suspend fun resolveNativeFeeTokenStatus(fromStatus: SwapCurrencyStatus): CryptoCurrencyStatus? { + return getFeePaidCryptoCurrencyStatusSyncUseCase( + userWalletId = fromStatus.userWalletId, + cryptoCurrencyStatus = fromStatus.status, + ).getOrNull() ?: run { + val feeNetwork = fromStatus.currency.network + + val feePaidCurrency = currenciesRepository.getFeePaidCurrency( + fromStatus.userWalletId, + feeNetwork, + ) + + val (feeCurrency, balance) = when (feePaidCurrency) { + FeePaidCurrency.Coin -> currenciesRepository.createCoinCurrency(feeNetwork) to + walletManagersFacade.getNativeTokenBalance( + userWalletId = fromStatus.userWalletId, + networkId = feeNetwork.rawId, + derivationPath = feeNetwork.derivationPath.value, + ) + is FeePaidCurrency.Token -> currenciesRepository.createTokenCurrency( + userWalletId = fromStatus.userWalletId, + contractAddress = feePaidCurrency.contractAddress, + networkId = feeNetwork.rawId, + ) to feePaidCurrency.balance + is FeePaidCurrency.FeeResource, + FeePaidCurrency.SameCurrency, + -> fromStatus.currency to fromStatus.status.value.amount } + + val feeCurrencyRawID = feeCurrency.id.rawCurrencyId ?: return@run null + val quote = quotesRepository.getMultiQuoteSyncOrNull(setOf(feeCurrencyRawID)) + ?.firstOrNull()?.value as? QuoteStatus.Data + + CryptoCurrencyStatus( + currency = feeCurrency, + value = if (quote == null) { + CryptoCurrencyStatus.NoQuote( + amount = balance.orZero(), + stakingBalance = null, + yieldSupplyStatus = null, + hasCurrentNetworkTransactions = false, + pendingTransactions = emptySet(), + networkAddress = fromStatus.status.value.networkAddress ?: return@run null, + sources = CryptoCurrencyStatus.Sources(), + ) + } else { + CryptoCurrencyStatus.Loaded( + amount = balance.orZero(), + fiatAmount = quote.fiatRate.multiply(balance), + fiatRate = quote.fiatRate, + priceChange = quote.priceChange, + stakingBalance = null, + yieldSupplyStatus = null, + hasCurrentNetworkTransactions = false, + pendingTransactions = emptySet(), + networkAddress = fromStatus.status.value.networkAddress ?: return@run null, + sources = CryptoCurrencyStatus.Sources(), + ) + }, + ) + } + } + + /** + * Patches an existing [SwapState.QuotesLoadedState] with a freshly resolved [SwapFee]. + * See [SwapInteractor.applySwapFee] for the full contract. + * + * Numeric fee used for downstream computation: + * - If `fee.selectedFeeToken.currency` is a token → `0` for the balance / include-fee math + * when the fee currency differs from the from-token (matches legacy `manageWarnings` + * semantics at line 422 of the pre-Phase-4 code). + * - Otherwise → `fee.fee.amount.value + fee.otherNativeFee` (the bridge-aware native fee). + * + * The fee is folded into a single [SwapBalanceStatus] by [computeBalanceStatus], which is + * then assigned to `preparedSwapConfigState.balanceStatus`. + */ + override suspend fun applySwapFee( + state: SwapState.QuotesLoadedState, + fee: SwapFee, + lastReducedBalanceBy: BigDecimal, + ): SwapState.QuotesLoadedState { + val fromSwapCurrencyStatus = state.fromTokenInfo.swapCurrencyStatus + val amount = state.fromTokenInfo.tokenAmount + val isFeeInToken = fee.selectedFeeToken.currency is CryptoCurrency.Token + val nativeFee = (fee.fee.amount.value ?: BigDecimal.ZERO) + fee.otherNativeFee + + // Mirrors legacy manageWarnings: token-fee paths skip the native deduction. + val warningsFee = if (isFeeInToken && fromSwapCurrencyStatus.currency.id != fee.selectedFeeToken.currency.id) { + BigDecimal.ZERO + } else { + nativeFee + } + + val balanceStatus = computeBalanceStatus( + fromSwapCurrencyStatus = fromSwapCurrencyStatus, + amount = amount, + reduceBalanceBy = lastReducedBalanceBy, + feeValue = nativeFee, + selectedFeeToken = fee.selectedFeeToken, + provider = state.swapProvider, + ) + val currencyCheck = manageWarnings( + fromSwapCurrencyStatus = fromSwapCurrencyStatus, + amount = amount, + fee = warningsFee, + balanceStatus = balanceStatus, + ) + val validationResult = manageTransactionValidationWarnings( + fromSwapCurrencyStatus = fromSwapCurrencyStatus, + amount = amount, + feeValue = nativeFee, + ) + val minAdaValue = (fee.fee as? Fee.CardanoToken)?.minAdaValue + + return state.copy( + preparedSwapConfigState = state.preparedSwapConfigState.copy( + balanceStatus = balanceStatus, + ), + currencyCheck = currencyCheck, + validationResult = validationResult, + minAdaValue = minAdaValue, + ) + } + + /** + * Decision tree (matches the user-approved derivation table plus the implicit Token-fee sub-case): + * 1. `Included` from `getIncludeFeeInAmountInternal` ⇒ [SwapBalanceStatus.FeeAdjustedAmount]. + * 2. `!isBalanceEnough` (from-token balance can't cover the amount itself) ⇒ + * [SwapBalanceStatus.InsufficientAmount]. + * 3. `feeBalanceState is NotEnough` ⇒ [SwapBalanceStatus.InsufficientFee]. This catches: + * - From-token is a Token, native balance can't cover the fee + * (legacy `includeFeeInAmount=BalanceNotEnough` for the Token branch). + * - From-token is a Coin and `balance - amount < fee` + * (legacy `feeState=NotEnough && includeFeeInAmount=Excluded`). + * 4. Otherwise ⇒ [SwapBalanceStatus.Sufficient]. + * + * The legacy ambiguity where `BalanceNotEnough` meant "amount > balance" for Coin + * from-currencies but "fee > native balance" for Token from-currencies is resolved here + * by consulting `isBalanceEnough` (amount-alone check) directly. + */ + private suspend fun computeBalanceStatus( + fromSwapCurrencyStatus: SwapCurrencyStatus, + amount: SwapAmount, + reduceBalanceBy: BigDecimal, + feeValue: BigDecimal, + selectedFeeToken: CryptoCurrencyStatus?, + provider: SwapProvider, + ): SwapBalanceStatus { + when (provider.type) { ExchangeProviderType.CEX -> { - val amountDecimal = toBigDecimalOrNull(amount) - if (amountDecimal == null || amountDecimal.signum() == 0) { - raise(GetFeeError.UnknownError) - } - - estimateFeeUseCase.invoke( - amount = amountDecimal, - userWallet = fromSwapCurrencyStatus.userWallet, - cryptoCurrencyStatus = fromSwapCurrencyStatus.status, - ).map { - it.patchTransactionFeeForSwap(INCREASE_GAS_LIMIT_FOR_SEND) + val includeStatus = getIncludeFeeInAmountInternal( + fromSwapCurrencyStatus = fromSwapCurrencyStatus, + amount = amount, + reduceBalanceBy = reduceBalanceBy, + feeValue = feeValue, + selectedFeeToken = selectedFeeToken, + ) + if (includeStatus is IncludeFeeInAmountInternal.Included) { + return SwapBalanceStatus.FeeAdjustedAmount(adjustedAmount = includeStatus.amountSubtractFee) } } + ExchangeProviderType.DEX, + ExchangeProviderType.DEX_BRIDGE, + -> Unit + } + + val isAmountAlone = isBalanceEnough(fromSwapCurrencyStatus, amount, fee = feeValue) + if (!isAmountAlone) { + return SwapBalanceStatus.InsufficientAmount + } + + val feeBalanceState = getFeeBalanceState( + fromSwapCurrencyStatus = fromSwapCurrencyStatus, + fee = feeValue, + spendAmount = amount, + selectedFeeToken = selectedFeeToken, + ) + return when (feeBalanceState) { + is FeeBalanceState.Enough -> SwapBalanceStatus.Sufficient + is FeeBalanceState.NotEnough -> SwapBalanceStatus.InsufficientFee( + feeCurrencyName = feeBalanceState.currencyName, + feeCurrencySymbol = feeBalanceState.currencySymbol, + ) } } @@ -1053,20 +1227,7 @@ internal class SwapInteractorImpl @Inject constructor( } override fun getTokenBalance(token: CryptoCurrencyStatus): SwapAmount { - return SwapAmount(token.value.amount ?: BigDecimal.ZERO, token.currency.decimals) - } - - override suspend fun getNativeToken(swapCurrencyStatus: SwapCurrencyStatus): CryptoCurrency { - val network = swapCurrencyStatus.currency.network - return multiWalletCryptoCurrenciesSupplier.getSyncOrNull( - params = MultiWalletCryptoCurrenciesProducer.Params(swapCurrencyStatus.userWalletId), - ) - ?.filterIsInstance() - ?.firstOrNull { nativeCoin -> - nativeCoin.network.id == network.id && - nativeCoin.network.derivationPath == network.derivationPath - } - ?: currenciesRepository.createCoinCurrency(network) + return SwapAmount(token.value.amount.orZero(), token.currency.decimals) } private suspend fun createEmptyAmountState(): SwapState { @@ -1083,96 +1244,6 @@ internal class SwapInteractorImpl @Inject constructor( ) } - /** - * Load quote data calls only if spend is not allowed for token contract address - */ - @Suppress("LongParameterList") - private suspend fun loadCexQuoteData( - fromSwapCurrencyStatus: SwapCurrencyStatus, - toSwapCurrencyStatus: SwapCurrencyStatus, - amount: SwapAmount, - reduceBalanceBy: BigDecimal, - provider: SwapProvider, - isAllowedToSpend: Boolean, - isBalanceWithoutFeeEnough: Boolean, - txFeeSealedState: TxFeeSealedState, - ): SwapState { - val fromToken = fromSwapCurrencyStatus.currency - val toToken = toSwapCurrencyStatus.currency - return coroutineScope { - val txFeeSealedStateUpdated = updateTxFeeStateIfNeededForCEX( - fromSwapCurrencyStatus = fromSwapCurrencyStatus, - txFeeSealedState = txFeeSealedState, - amount = amount, - ) - - val includeFeeInAmount = getIncludeFeeInAmount( - fromSwapCurrencyStatus = fromSwapCurrencyStatus, - amount = amount, - reduceBalanceBy = reduceBalanceBy, - txFeeSealedState = txFeeSealedStateUpdated, - ) - - val amountToRequest = if (includeFeeInAmount is IncludeFeeInAmount.Included) { - includeFeeInAmount.amountSubtractFee - } else { - amount - } - - val quotes = repository.findBestQuote( - userWallet = fromSwapCurrencyStatus.userWallet, - fromContractAddress = fromToken.getContractAddress(), - fromNetwork = fromToken.network.rawId, - toContractAddress = toToken.getContractAddress(), - toNetwork = toToken.network.rawId, - fromAmount = amountToRequest.toStringWithRightOffset(), - fromDecimals = amount.decimals, - toDecimals = toToken.decimals, - providerId = provider.providerId, - rateType = RateType.FLOAT, - ) - - getQuotesState( - provider = provider, - quoteDataModel = quotes, - amount = amount, - fromSwapCurrencyStatus = fromSwapCurrencyStatus, - toSwapCurrencyStatus = toSwapCurrencyStatus, - isAllowedToSpend = isAllowedToSpend, - isBalanceWithoutFeeEnough = isBalanceWithoutFeeEnough, - txFeeSealedState = txFeeSealedState, - includeFeeInAmount = includeFeeInAmount, - ) - } - } - - private suspend fun updateTxFeeStateIfNeededForCEX( - fromSwapCurrencyStatus: SwapCurrencyStatus, - txFeeSealedState: TxFeeSealedState, - amount: SwapAmount, - ): TxFeeSealedState { - return when (txFeeSealedState) { - is TxFeeSealedState.Component -> txFeeSealedState - is TxFeeSealedState.Legacy -> { - if (txFeeSealedState.txFeeState is TxFeeState.Empty) { - val txFeeResult = estimateFeeUseCase( - amount = amount.value, - userWallet = fromSwapCurrencyStatus.userWallet, - cryptoCurrencyStatus = fromSwapCurrencyStatus.status, - ) - val txFee = getFeeForCex(txFeeResult, fromSwapCurrencyStatus) - - TxFeeSealedState.Legacy( - txFeeState = txFee, - selectedFee = txFeeSealedState.selectedFee, - ) - } else { - txFeeSealedState - } - } - } - } - @Suppress("LongMethod") private suspend fun getQuotesState( provider: SwapProvider, @@ -1181,9 +1252,7 @@ internal class SwapInteractorImpl @Inject constructor( fromSwapCurrencyStatus: SwapCurrencyStatus, toSwapCurrencyStatus: SwapCurrencyStatus, isAllowedToSpend: Boolean, - isBalanceWithoutFeeEnough: Boolean, - txFeeSealedState: TxFeeSealedState, - includeFeeInAmount: IncludeFeeInAmount, + quoteBalanceStatus: SwapBalanceStatus, ): SwapState { return quoteDataModel.fold( ifRight = { quoteModel -> @@ -1193,51 +1262,20 @@ internal class SwapInteractorImpl @Inject constructor( fromTokenAmount = amount, toTokenAmount = quoteModel.toTokenAmount, swapData = null, - txFeeSealedState = txFeeSealedState, provider = provider, ).copy( currencyCheck = manageWarnings( fromSwapCurrencyStatus = fromSwapCurrencyStatus, amount = amount, - txFeeSealed = txFeeSealedState, - includeFeeInAmount = includeFeeInAmount, + fee = BigDecimal.ZERO, + balanceStatus = quoteBalanceStatus, ), validationResult = manageTransactionValidationWarnings( fromSwapCurrencyStatus = fromSwapCurrencyStatus, amount = amount, - txFeeSealedState = txFeeSealedState, + feeValue = BigDecimal.ZERO, ), - minAdaValue = when (txFeeSealedState) { - is TxFeeSealedState.Component -> { - (txFeeSealedState.txFee.fee as? Fee.CardanoToken)?.minAdaValue - } - is TxFeeSealedState.Legacy -> { - when (txFeeSealedState.txFeeState) { - TxFeeState.Empty -> null - is TxFeeState.MultipleFeeState -> - (txFeeSealedState.txFeeState.normalFee.fee as? Fee.CardanoToken)?.minAdaValue - is TxFeeState.SingleFeeState -> - (txFeeSealedState.txFeeState.fee.fee as? Fee.CardanoToken)?.minAdaValue - } - } - }, - ) - - val fee = when (txFeeSealedState) { - is TxFeeSealedState.Component -> txFeeSealedState.txFee.fee.amount.value - is TxFeeSealedState.Legacy -> { - when (val txFee = txFeeSealedState.txFeeState) { - TxFeeState.Empty -> BigDecimal.ZERO - is TxFeeState.MultipleFeeState -> txFee.priorityFee.fee.amount.value - is TxFeeState.SingleFeeState -> txFee.fee.fee.amount.value - } - } - } - - val feeState = getFeeState( - fromSwapCurrencyStatus = fromSwapCurrencyStatus, - fee = fee, - spendAmount = amount, + minAdaValue = null, ) when (provider.type) { @@ -1252,8 +1290,7 @@ internal class SwapInteractorImpl @Inject constructor( if (state !is SwapState.QuotesLoadedState) return state state.copy( preparedSwapConfigState = state.preparedSwapConfigState.copy( - isBalanceEnough = isBalanceWithoutFeeEnough, - feeState = feeState, + balanceStatus = quoteBalanceStatus, ), ) } @@ -1261,10 +1298,8 @@ internal class SwapInteractorImpl @Inject constructor( swapState.copy( permissionState = PermissionDataState.Empty, preparedSwapConfigState = PreparedSwapConfigState( - feeState = feeState, - isBalanceEnough = isBalanceWithoutFeeEnough, + balanceStatus = quoteBalanceStatus, hasOutgoingTransaction = hasOutgoingTransaction(fromSwapCurrencyStatus.status), - includeFeeInAmount = includeFeeInAmount, ), ) } @@ -1274,7 +1309,7 @@ internal class SwapInteractorImpl @Inject constructor( createSwapErrorWith( fromSwapCurrencyStatus = fromSwapCurrencyStatus, amount = amount, - includeFeeInAmount = includeFeeInAmount, + balanceStatus = quoteBalanceStatus, expressDataError = error, ) }, @@ -1284,7 +1319,7 @@ internal class SwapInteractorImpl @Inject constructor( private suspend fun createSwapErrorWith( fromSwapCurrencyStatus: SwapCurrencyStatus, amount: SwapAmount, - includeFeeInAmount: IncludeFeeInAmount, + balanceStatus: SwapBalanceStatus, expressDataError: ExpressDataError, ): SwapState.SwapError { val rates = getQuotes(fromSwapCurrencyStatus.currency.id) @@ -1293,72 +1328,56 @@ internal class SwapInteractorImpl @Inject constructor( tokenAmount = amount, amountFiat = rates[fromSwapCurrencyStatus.currency.id]?.fiatRate?.multiply(amount.value) ?: BigDecimal.ZERO, ) - return SwapState.SwapError(fromTokenSwapInfo, expressDataError, includeFeeInAmount) + return SwapState.SwapError(fromTokenSwapInfo, expressDataError, balanceStatus) } - @Suppress("CyclomaticComplexMethod", "NestedBlockDepth", "CastNullableToNonNullableType") - private suspend fun getIncludeFeeInAmount( + /** + * Branches: + * - [selectedFeeToken] is the same currency as [fromSwapCurrencyStatus] (and not a coin) → + * same-currency-token path: balance check on the from-token's own balance. + * - Otherwise → native-fee branch via [getIncludeFeeInAmountForNative]. + * + * Used both by [loadCexQuoteData] (with `feeValue = ZERO` at quote stage) and by + * [computeBalanceStatus] (with the actual fee once the selector resolves). + */ + private suspend fun getIncludeFeeInAmountInternal( fromSwapCurrencyStatus: SwapCurrencyStatus, amount: SwapAmount, reduceBalanceBy: BigDecimal, - txFeeSealedState: TxFeeSealedState, - ): IncludeFeeInAmount { - return when (txFeeSealedState) { - is TxFeeSealedState.Component -> { - if (fromSwapCurrencyStatus.currency.id == txFeeSealedState.txFee.selectedToken?.currency?.id) { - val fee = txFeeSealedState.txFee.fee.amount.value ?: BigDecimal.ZERO - if (txFeeSealedState.txFee.selectedToken.currency is CryptoCurrency.Coin) { - getIncludeFeeInAmountForNative( - fromSwapCurrencyStatus = fromSwapCurrencyStatus, - amount = amount, - reduceBalanceBy = reduceBalanceBy, - feeValue = fee, + feeValue: BigDecimal, + selectedFeeToken: CryptoCurrencyStatus? = null, + ): IncludeFeeInAmountInternal { + val isFeeInSameCurrencyToken = selectedFeeToken != null && + fromSwapCurrencyStatus.currency.id == selectedFeeToken.currency.id && + selectedFeeToken.currency is CryptoCurrency.Token + + return if (isFeeInSameCurrencyToken) { + // we have a token selected for fee payment the same as sending token + val fromBalance = fromSwapCurrencyStatus.status.value.amount + val reducedBalance = fromBalance?.minus(reduceBalanceBy).orZero() + when { + amount.value > reducedBalance -> IncludeFeeInAmountInternal.BalanceNotEnough + amount.value + feeValue <= reducedBalance -> IncludeFeeInAmountInternal.Excluded + else -> { + if (feeValue < amount.value) { + IncludeFeeInAmountInternal.Included( + amountSubtractFee = SwapAmount( + value = reducedBalance - feeValue, + decimals = fromSwapCurrencyStatus.currency.decimals, + ), ) } else { - // we have a token selected for fee payment the same as sending token - val reducedBalance = fromSwapCurrencyStatus.status.value.amount as BigDecimal - reduceBalanceBy - when { - amount.value > reducedBalance -> IncludeFeeInAmount.BalanceNotEnough - amount.value + fee <= reducedBalance -> IncludeFeeInAmount.Excluded - else -> { - if (fee < amount.value) { - IncludeFeeInAmount.Included( - amountSubtractFee = SwapAmount( - value = reducedBalance - fee, - decimals = fromSwapCurrencyStatus.currency.decimals, - ), - ) - } else { - IncludeFeeInAmount.Excluded - } - } - } + IncludeFeeInAmountInternal.Excluded } - } else { - val fee = txFeeSealedState.txFee.fee.amount.value ?: BigDecimal.ZERO - getIncludeFeeInAmountForNative( - fromSwapCurrencyStatus = fromSwapCurrencyStatus, - amount = amount, - reduceBalanceBy = reduceBalanceBy, - feeValue = fee, - ) } } - is TxFeeSealedState.Legacy -> { - val feeValue = when (val txFee = txFeeSealedState.txFeeState) { - TxFeeState.Empty -> BigDecimal.ZERO - is TxFeeState.MultipleFeeState -> txFee.getFeeByType( - txFeeSealedState.selectedFee, - ).feeIncludeOtherNativeFee - is TxFeeState.SingleFeeState -> txFee.fee.feeIncludeOtherNativeFee - } - getIncludeFeeInAmountForNative( - fromSwapCurrencyStatus = fromSwapCurrencyStatus, - amount = amount, - reduceBalanceBy = reduceBalanceBy, - feeValue = feeValue, - ) - } + } else { + getIncludeFeeInAmountForNative( + fromSwapCurrencyStatus = fromSwapCurrencyStatus, + amount = amount, + reduceBalanceBy = reduceBalanceBy, + feeValue = feeValue, + ) } } @@ -1367,13 +1386,13 @@ internal class SwapInteractorImpl @Inject constructor( amount: SwapAmount, reduceBalanceBy: BigDecimal, feeValue: BigDecimal, - ): IncludeFeeInAmount { + ): IncludeFeeInAmountInternal { return when (val feePaidCurrency = getFeePaidCurrency(fromSwapCurrencyStatus)) { is FeePaidCurrency.Token -> { if (feePaidCurrency.balance > feeValue) { - IncludeFeeInAmount.Excluded + IncludeFeeInAmountInternal.Excluded } else { - IncludeFeeInAmount.BalanceNotEnough + IncludeFeeInAmountInternal.BalanceNotEnough } } else -> getIncludeFeeAmountForCoinFee( @@ -1390,7 +1409,7 @@ internal class SwapInteractorImpl @Inject constructor( amount: SwapAmount, reduceBalanceBy: BigDecimal, feeValue: BigDecimal, - ): IncludeFeeInAmount { + ): IncludeFeeInAmountInternal { val networkId = fromSwapCurrencyStatus.currency.network.rawId val tokenForFeeBalance = walletManagersFacade.getNativeTokenBalance( userWalletId = fromSwapCurrencyStatus.userWalletId, @@ -1402,73 +1421,56 @@ internal class SwapInteractorImpl @Inject constructor( return when { fromSwapCurrencyStatus.currency is CryptoCurrency.Token -> { if (feeValue > reducedBalance || reducedBalance.signum() == 0) { - IncludeFeeInAmount.BalanceNotEnough + IncludeFeeInAmountInternal.BalanceNotEnough } else { - IncludeFeeInAmount.Excluded + IncludeFeeInAmountInternal.Excluded } } amount.value > reducedBalance -> { - IncludeFeeInAmount.BalanceNotEnough + IncludeFeeInAmountInternal.BalanceNotEnough } amountWithFee <= reducedBalance -> { - IncludeFeeInAmount.Excluded + IncludeFeeInAmountInternal.Excluded } else -> { if (feeValue < amount.value) { val nativeCoinDecimals = Blockchain.fromNetworkId(networkId)?.decimals() ?: error("Blockchain not found") - IncludeFeeInAmount.Included( + IncludeFeeInAmountInternal.Included( amountSubtractFee = SwapAmount( reducedBalance - feeValue, nativeCoinDecimals, ), ) } else { - IncludeFeeInAmount.BalanceNotEnough + IncludeFeeInAmountInternal.BalanceNotEnough } } } } - private suspend fun getFormattedFiatFees( - fromSwapCurrencyStatus: SwapCurrencyStatus, - vararg fees: BigDecimal, - ): List { - val appCurrency = getSelectedAppCurrencyUseCase.unwrap() - val feeCurrencyId: CryptoCurrency.ID = when (val feePaidCurrency = getFeePaidCurrency(fromSwapCurrencyStatus)) { - is FeePaidCurrency.Token -> feePaidCurrency.tokenId - else -> getNativeToken(fromSwapCurrencyStatus).id - } - val rates = getQuotes(feeCurrencyId) - return rates[feeCurrencyId]?.let { rate -> - fees.map { fee -> - rate.fiatRate.multiply(fee).format { - fiat( - fiatCurrencyCode = appCurrency.code, - fiatCurrencySymbol = appCurrency.symbol, - ) - } - } - }.orEmpty() - } - /** - * Load swap data calls only if spend is allowed for token contract address + * DEX-swap-data loader that does not compute a fee. + * + * The fee is owned exclusively by the fee selector (`FeeSelectorBlockComponent`). This method + * fetches the swap data via [SwapRepository.getExchangeData], populates `swapDataModel`, and + * returns an initial [SwapState.QuotesLoadedState] with: + * - `preparedSwapConfigState.balanceStatus = SwapBalanceStatus.Pending` — transient until + * `applySwapFee` is called. + * - `currencyCheck`, `validationResult`, `minAdaValue` populated with `fee = 0` (re-derived once fee is known). */ - @Suppress("LongParameterList", "LongMethod") - private suspend fun loadDexSwapData( + @Suppress("LongMethod") + private suspend fun loadDexSwapDataNoFee( provider: SwapProvider, fromSwapCurrencyStatus: SwapCurrencyStatus, toSwapCurrencyStatus: SwapCurrencyStatus, amount: SwapAmount, - txFeeSealedState: TxFeeSealedState, expressOperationType: ExpressOperationType, ): SwapState { val fromNetworkAddress = fromSwapCurrencyStatus.status.value.networkAddress val dexFromAddress = fromNetworkAddress?.defaultAddress?.value.orEmpty() val toNetworkAddress = toSwapCurrencyStatus.status.value.networkAddress val dexToAddress = toNetworkAddress?.defaultAddress?.value.orEmpty() - val networkId = fromSwapCurrencyStatus.currency.network.rawId return repository.getExchangeData( userWallet = fromSwapCurrencyStatus.userWallet, fromContractAddress = fromSwapCurrencyStatus.currency.getContractAddress(), @@ -1486,43 +1488,9 @@ internal class SwapInteractorImpl @Inject constructor( expressOperationType = expressOperationType, ).fold( ifRight = { swapData -> - val transaction = swapData.transaction as ExpressTransactionModel.DEX - val nativeCoinDecimals = - Blockchain.fromNetworkId(networkId)?.decimals() ?: error("Blockchain not found") - val otherNativeFee = transaction.otherNativeFeeWei?.movePointLeft(nativeCoinDecimals) ?: BigDecimal.ZERO - - val txFeeState = loadFeeForDex( - fromSwapCurrencyStatus = fromSwapCurrencyStatus, - transaction = transaction, - ).getOrElse { error -> - return@fold produceDexSwapDataError( - fromSwapCurrencyStatus = fromSwapCurrencyStatus, - error = error, - amount = amount, - ) - }.toTxFeeState(fromSwapCurrencyStatus, otherNativeFee) - - val includeFeeInAmount = IncludeFeeInAmount.Excluded // exclude for dex - val feeByPriority = when (txFeeSealedState) { - is TxFeeSealedState.Component -> { - txFeeSealedState.txFee.fee.amount.value ?: BigDecimal.ZERO - } - is TxFeeSealedState.Legacy -> { - selectFeeByType(feeType = txFeeSealedState.selectedFee, txFeeState = txFeeState) - } - } - val feeToCheckFunds = feeByPriority + (otherNativeFee ?: BigDecimal.ZERO) - val isBalanceIncludeFeeEnough = isBalanceEnough(fromSwapCurrencyStatus, amount, feeToCheckFunds) - val feeState = getFeeState( - fromSwapCurrencyStatus = fromSwapCurrencyStatus, - fee = feeToCheckFunds, - spendAmount = amount, - ) val preparedSwapConfigState = PreparedSwapConfigState( - isBalanceEnough = isBalanceIncludeFeeEnough, - feeState = feeState, + balanceStatus = SwapBalanceStatus.Pending, hasOutgoingTransaction = hasOutgoingTransaction(fromSwapCurrencyStatus.status), - includeFeeInAmount = includeFeeInAmount, ) val swapState = updateBalances( fromSwapCurrencyStatus = fromSwapCurrencyStatus, @@ -1530,7 +1498,6 @@ internal class SwapInteractorImpl @Inject constructor( fromTokenAmount = amount, toTokenAmount = swapData.toTokenAmount, swapData = swapData, - txFeeSealedState = txFeeSealedState, provider = provider, ) swapState.copy( @@ -1538,13 +1505,13 @@ internal class SwapInteractorImpl @Inject constructor( currencyCheck = manageWarnings( fromSwapCurrencyStatus = fromSwapCurrencyStatus, amount = amount, - txFeeSealed = txFeeSealedState, - includeFeeInAmount = includeFeeInAmount, + fee = BigDecimal.ZERO, + balanceStatus = SwapBalanceStatus.Pending, ), validationResult = manageTransactionValidationWarnings( fromSwapCurrencyStatus = fromSwapCurrencyStatus, amount = amount, - txFeeSealedState = txFeeSealedState, + feeValue = BigDecimal.ZERO, ), preparedSwapConfigState = preparedSwapConfigState, ) @@ -1559,35 +1526,6 @@ internal class SwapInteractorImpl @Inject constructor( ) } - private suspend fun loadFeeForDex( - fromSwapCurrencyStatus: SwapCurrencyStatus, - transaction: ExpressTransactionModel.DEX, - ): Either = either { - if (isSolana(fromSwapCurrencyStatus.currency.network.rawId)) { - val transactionBytes = Base64.decode(transaction.txData, Base64.NO_WRAP) - - val formattedHash = getFormattedHash(transactionBytes) - - if (formattedHash.size > SOLANA_TRANSACTION_SIZE_THRESHOLD_BYTES && - fromSwapCurrencyStatus.userWallet is UserWallet.Cold - ) { - raise(ExpressDataError.TooLargeSolanaTransactionError) - } - - getFeeDataForSolanaDexSwap( - fromSwapCurrencyStatus = fromSwapCurrencyStatus, - transactionBytes = transactionBytes, - ) - } else { - getFeeDataForDexSwap( - fromSwapCurrencyStatus = fromSwapCurrencyStatus, - transaction = transaction, - ).map { fee -> - (fee as TransactionFeeResult.Loaded).fee.patchTransactionFeeForSwap(INCREASE_GAS_LIMIT_FOR_DEX) - }.bind() - } - } - private suspend fun produceDexSwapDataError( fromSwapCurrencyStatus: SwapCurrencyStatus, error: ExpressDataError, @@ -1600,89 +1538,12 @@ internal class SwapInteractorImpl @Inject constructor( amountFiat = rates[fromSwapCurrencyStatus.currency.id]?.fiatRate?.multiply(amount.value) ?: BigDecimal.ZERO, ) return SwapState.SwapError( - fromTokenSwapInfo, - error, - IncludeFeeInAmount.Excluded, + fromTokenInfo = fromTokenSwapInfo, + error = error, + balanceStatus = SwapBalanceStatus.Pending, ) } - @Suppress("CyclomaticComplexMethod") - private suspend fun getFeeDataForDexSwap( - fromSwapCurrencyStatus: SwapCurrencyStatus, - transaction: ExpressTransactionModel.DEX, - selectedToken: CryptoCurrencyStatus? = null, - ): Either = 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) } - ?: error("can't get fee for getEthSpecificFeeUseCase") - } - } - - 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") - } - @Suppress("LongParameterList", "MaxChainedCallsOnSameLine") private suspend fun updateBalances( provider: SwapProvider, @@ -1691,12 +1552,10 @@ internal class SwapInteractorImpl @Inject constructor( fromTokenAmount: SwapAmount, toTokenAmount: SwapAmount, swapData: SwapDataModel?, - txFeeSealedState: TxFeeSealedState, ): SwapState.QuotesLoadedState { val fromToken = fromSwapCurrencyStatus.currency val toToken = toSwapCurrencyStatus.currency - val nativeToken = getNativeToken(fromSwapCurrencyStatus) - val rates = getQuotes(fromToken.id, toToken.id, nativeToken.id) + val rates = getQuotes(fromToken.id, toToken.id) return SwapState.QuotesLoadedState( fromTokenInfo = TokenSwapInfo( tokenAmount = fromTokenAmount, @@ -1716,39 +1575,10 @@ internal class SwapInteractorImpl @Inject constructor( ), swapDataModel = swapData, swapProvider = provider, - txFee = when (txFeeSealedState) { - is TxFeeSealedState.Component -> { - when (txFeeSealedState.txFee.transactionFeeResult) { - is TransactionFeeResult.Loaded -> - txFeeSealedState.txFee.transactionFeeResult.fee.toTxFeeState( - fromSwapCurrencyStatus = fromSwapCurrencyStatus, - otherNativeFee = null, - ) - is TransactionFeeResult.LoadedExtended -> - txFeeSealedState.txFee.transactionFeeResult.fee.transactionFee.toTxFeeState( - fromSwapCurrencyStatus = fromSwapCurrencyStatus, - otherNativeFee = null, - ) - } - } - is TxFeeSealedState.Legacy -> txFeeSealedState.txFeeState - }, minAdaValue = null, ) } - private suspend fun getFeeForCex( - txFeeResult: Either?, - fromSwapCurrencyStatus: SwapCurrencyStatus, - ): TxFeeState { - return txFeeResult?.fold( - ifLeft = { TxFeeState.Empty }, - ifRight = { txFee -> - txFee.patchTransactionFeeForSwap(INCREASE_GAS_LIMIT_FOR_SEND).toTxFeeState(fromSwapCurrencyStatus, null) - }, - ) ?: TxFeeState.Empty - } - private suspend fun updatePermissionState( fromSwapCurrencyStatus: SwapCurrencyStatus, swapAmount: SwapAmount, @@ -1790,104 +1620,6 @@ internal class SwapInteractorImpl @Inject constructor( ) } - @Suppress("LongMethod") - private suspend fun TransactionFee.toTxFeeState( - fromSwapCurrencyStatus: SwapCurrencyStatus, - otherNativeFee: BigDecimal?, - ): TxFeeState { - val otherNativeFeeValue = otherNativeFee ?: BigDecimal.ZERO - return when (this) { - is TransactionFee.Choosable -> { - val feeNormal = this.normal.amount.value ?: BigDecimal.ZERO - val feePriority = this.priority.amount.value ?: BigDecimal.ZERO - val normalFiatValue = getFormattedFiatFees(fromSwapCurrencyStatus, feeNormal)[0] - val priorityFiatValue = getFormattedFiatFees(fromSwapCurrencyStatus, feePriority)[0] - - val normalCryptoFee = amountFormatter.formatBigDecimalAmountToUI( - amount = feeNormal, - decimals = this.normal.amount.decimals, - ) - val priorityCryptoFee = amountFormatter.formatBigDecimalAmountToUI( - amount = feePriority, - decimals = this.priority.amount.decimals, - ) - - // region otherNativeFee - val normalFeeWithOtherNative = feeNormal + otherNativeFeeValue - val priorityFeeWithOtherNative = feePriority + otherNativeFeeValue - val normalFiatValueWithNative = - getFormattedFiatFees(fromSwapCurrencyStatus, normalFeeWithOtherNative)[0] - val priorityFiatValueWithNative = - getFormattedFiatFees(fromSwapCurrencyStatus, priorityFeeWithOtherNative)[0] - - val normalCryptoFeeWithNative = amountFormatter.formatBigDecimalAmountToUI( - amount = normalFeeWithOtherNative, - decimals = this.normal.amount.decimals, - ) - val priorityCryptoFeeWithNative = amountFormatter.formatBigDecimalAmountToUI( - amount = priorityFeeWithOtherNative, - decimals = this.priority.amount.decimals, - ) - // endregion - TxFeeState.MultipleFeeState( - normalFee = TxFee.Legacy( - feeValue = feeNormal, - feeFiatFormatted = normalFiatValue, - feeCryptoFormatted = normalCryptoFee, - feeIncludeOtherNativeFee = normalFeeWithOtherNative, - feeFiatFormattedWithNative = normalFiatValueWithNative, - feeCryptoFormattedWithNative = normalCryptoFeeWithNative, - cryptoSymbol = this.normal.amount.currencySymbol, - feeType = FeeType.NORMAL, - fee = this.normal, - ), - priorityFee = TxFee.Legacy( - feeValue = feePriority, - feeFiatFormatted = priorityFiatValue, - feeCryptoFormatted = priorityCryptoFee, - feeIncludeOtherNativeFee = priorityFeeWithOtherNative, - feeFiatFormattedWithNative = priorityFiatValueWithNative, - feeCryptoFormattedWithNative = priorityCryptoFeeWithNative, - cryptoSymbol = this.priority.amount.currencySymbol, - feeType = FeeType.PRIORITY, - fee = this.priority, - ), - ) - } - is TransactionFee.Single -> { - val feeNormal = this.normal.amount.value ?: BigDecimal.ZERO - val normalFiatValue = getFormattedFiatFees(fromSwapCurrencyStatus, feeNormal)[0] - val normalCryptoFee = amountFormatter.formatBigDecimalAmountToUI( - amount = feeNormal, - decimals = this.normal.amount.decimals, - ) - // region otherNativeFee - val normalFeeWithOtherNative = feeNormal + otherNativeFeeValue - val normalFiatValueWithNative = - getFormattedFiatFees(fromSwapCurrencyStatus, normalFeeWithOtherNative)[0] - - val normalCryptoFeeWithNative = amountFormatter.formatBigDecimalAmountToUI( - amount = normalFeeWithOtherNative, - decimals = this.normal.amount.decimals, - ) - // endregion - TxFeeState.SingleFeeState( - fee = TxFee.Legacy( - feeValue = this.normal.amount.value ?: BigDecimal.ZERO, - feeFiatFormatted = normalFiatValue, - feeCryptoFormatted = normalCryptoFee, - feeIncludeOtherNativeFee = normalFeeWithOtherNative, - feeFiatFormattedWithNative = normalFiatValueWithNative, - feeCryptoFormattedWithNative = normalCryptoFeeWithNative, - cryptoSymbol = normal.amount.currencySymbol, - feeType = FeeType.NORMAL, - fee = this.normal, - ), - ) - } - } - } - private fun createNativeAmountForDex(txValueAmount: String, network: Network): Amount { val nativeDecimals = Blockchain.fromNetworkId(network.rawId)?.decimals() ?: error("Blockchain not found") @@ -1900,70 +1632,6 @@ internal class SwapInteractorImpl @Inject constructor( ) } - /** - * We need to increase gasLimit for Ethereum fees for 2 cases - * - * DEX: for dex calculated gasLimit for given data might be changed when transaction processing - * for that case dex providers recommend to increase gasLimit for few percents to ensure transaction completes - * - * CEX: for that case we calculate fee for random generated address and gasLimit might be different for it - * and result address to send. That's why we should increase gasLimit a little - * - */ - private fun TransactionFee.patchTransactionFeeForSwap(increaseBy: Int): TransactionFee { - return when (this) { - is TransactionFee.Choosable -> { - this.copy( - minimum = this.minimum.increaseEthGasLimitInNeeded(increaseBy), - normal = this.normal.increaseEthGasLimitInNeeded(increaseBy), - priority = this.priority.increaseEthGasLimitInNeeded(increaseBy), - ) - } - is TransactionFee.Single -> this.copy(normal = this.normal.increaseEthGasLimitInNeeded(increaseBy)) - } - } - - 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 - } - } - - /** - * Increase gasLimit for Fee.Ethereum - */ - private fun Fee.increaseGasLimitBy(percentage: Int): Fee { - if (this !is Fee.Ethereum) return this - val gasLimit = this.gasLimit - if (gasLimit == BigInteger.ZERO) return this - val increasedGasPrice = this.amount.value?.movePointRight(this.amount.decimals) - ?.divide(gasLimit.toBigDecimal(), RoundingMode.HALF_UP) - val increasedGasLimit = gasLimit.multiply(percentage.toBigInteger()).divide(hundredPercent) - 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]") - } - } - private fun hasOutgoingTransaction(cryptoCurrencyStatuses: CryptoCurrencyStatus): Boolean { return cryptoCurrencyStatuses.value.pendingTransactions.any { it.isOutgoing } } @@ -1978,17 +1646,6 @@ internal class SwapInteractorImpl @Inject constructor( } } - private fun selectFeeByType(feeType: FeeType, txFeeState: TxFeeState): BigDecimal { - return when (txFeeState) { - TxFeeState.Empty -> BigDecimal.ZERO - is TxFeeState.SingleFeeState -> txFeeState.fee.fee.amount.value - is TxFeeState.MultipleFeeState -> when (feeType) { - FeeType.NORMAL -> txFeeState.normalFee.fee.amount.value - FeeType.PRIORITY -> txFeeState.priorityFee.fee.amount.value - } - } ?: BigDecimal.ZERO - } - private suspend fun isBalanceEnough( fromSwapCurrencyStatus: SwapCurrencyStatus, amount: SwapAmount, @@ -2031,16 +1688,30 @@ internal class SwapInteractorImpl @Inject constructor( } @Suppress("LongMethod", "CyclomaticComplexMethod") - private suspend fun getFeeState( + private suspend fun getFeeBalanceState( fromSwapCurrencyStatus: SwapCurrencyStatus, fee: BigDecimal?, spendAmount: SwapAmount, - ): SwapFeeState { + selectedFeeToken: CryptoCurrencyStatus? = null, + ): FeeBalanceState { if (fee == null) { - return SwapFeeState.NotEnough() + return FeeBalanceState.NotEnough() } val fromCurrency = fromSwapCurrencyStatus.currency val percentsToFeeIncrease = BigDecimal.ONE + // When the user explicitly picked a non-native fee token (gasless flow), + // the balance check must verify the chosen token's balance, not the network's native coin. + if (selectedFeeToken != null && selectedFeeToken.currency is CryptoCurrency.Token) { + val feeTokenBalance = selectedFeeToken.value.amount ?: BigDecimal.ZERO + return if (feeTokenBalance > fee.multiply(percentsToFeeIncrease)) { + FeeBalanceState.Enough + } else { + FeeBalanceState.NotEnough( + currencyName = selectedFeeToken.currency.name, + currencySymbol = selectedFeeToken.currency.symbol, + ) + } + } return when (val feePaidCurrency = getFeePaidCurrency(fromSwapCurrencyStatus)) { FeePaidCurrency.Coin -> { val nativeTokenBalance = walletManagersFacade.getNativeTokenBalance( @@ -2057,21 +1728,20 @@ internal class SwapInteractorImpl @Inject constructor( } } if (balanceToCheck > fee.multiply(percentsToFeeIncrease)) { - SwapFeeState.Enough + FeeBalanceState.Enough } else { - val nativeToken = getNativeToken(fromSwapCurrencyStatus) - SwapFeeState.NotEnough( - currencyName = nativeToken.network.name, - currencySymbol = nativeToken.symbol, + FeeBalanceState.NotEnough( + currencyName = fromSwapCurrencyStatus.currency.name, + currencySymbol = fromSwapCurrencyStatus.currency.symbol, ) } } FeePaidCurrency.SameCurrency -> { - val balance = fromSwapCurrencyStatus.status.value.amount ?: return SwapFeeState.NotEnough() + val balance = fromSwapCurrencyStatus.status.value.amount ?: return FeeBalanceState.NotEnough() if (balance.minus(spendAmount.value) > fee.multiply(percentsToFeeIncrease)) { - SwapFeeState.Enough + FeeBalanceState.Enough } else { - SwapFeeState.NotEnough( + FeeBalanceState.NotEnough( currencyName = fromCurrency.name, currencySymbol = fromCurrency.symbol, ) @@ -2079,9 +1749,9 @@ internal class SwapInteractorImpl @Inject constructor( } is FeePaidCurrency.Token -> { if (feePaidCurrency.balance > fee.multiply(percentsToFeeIncrease)) { - SwapFeeState.Enough + FeeBalanceState.Enough } else { - SwapFeeState.NotEnough( + FeeBalanceState.NotEnough( currencyName = feePaidCurrency.name, currencySymbol = feePaidCurrency.symbol, ) @@ -2095,9 +1765,9 @@ internal class SwapInteractorImpl @Inject constructor( ) if (isFeeResourceEnough) { - SwapFeeState.Enough + FeeBalanceState.Enough } else { - SwapFeeState.NotEnough() + FeeBalanceState.NotEnough() } } } @@ -2192,16 +1862,6 @@ internal class SwapInteractorImpl @Inject constructor( return networkId == Blockchain.Solana.toNetworkId() } - // TODO create usecase [REDACTED_TASK_KEY] - 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 - } - } - private fun getPayoutAddress(txData: TransactionData.Uncompiled): String { val ethereumCallData = (txData.extras as? EthereumTransactionExtras)?.callData return if (ethereumCallData is EthereumYieldSupplySendCallData) { @@ -2246,8 +1906,6 @@ internal class SwapInteractorImpl @Inject constructor( // endregion companion object { - private const val INCREASE_GAS_LIMIT_FOR_DEX = 112 // 12% - private const val INCREASE_GAS_LIMIT_FOR_SEND = 105 // 5% private val PRICE_IMPACT_AMOUNT_MIN_THRESHOLD = 25.toBigDecimal() // in USD private val PRICE_IMPACT_AMOUNT_MAX_THRESHOLD = 5000.toBigDecimal() // in USD private val PRICE_IMPACT_AMOUNT_LOW_THRESHOLD = 100_000.toBigDecimal() // in USD @@ -2256,17 +1914,24 @@ internal class SwapInteractorImpl @Inject constructor( } } -sealed class TxFeeSealedState { - class Legacy(val txFeeState: TxFeeState, val selectedFee: FeeType) : TxFeeSealedState() - class Component(val txFee: TxFee.FeeComponent) : TxFeeSealedState() +/** + * [REDACTED_TASK_KEY] — internal classifier replacing the deleted public `IncludeFeeInAmount` enum. + * Kept private to [SwapInteractorImpl]; consumers see only [SwapBalanceStatus]. + */ +private sealed interface IncludeFeeInAmountInternal { + data class Included(val amountSubtractFee: SwapAmount) : IncludeFeeInAmountInternal + data object Excluded : IncludeFeeInAmountInternal + data object BalanceNotEnough : IncludeFeeInAmountInternal } -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) - } +/** + * [REDACTED_TASK_KEY] — internal classifier replacing the deleted public `SwapFeeState`. Kept private to + * [SwapInteractorImpl]; consumers see only [SwapBalanceStatus]. + */ +private sealed interface FeeBalanceState { + data object Enough : FeeBalanceState + data class NotEnough( + val currencyName: String? = null, + val currencySymbol: String? = null, + ) : FeeBalanceState } \ No newline at end of file diff --git a/features/swap/domain/src/main/java/com/tangem/feature/swap/domain/di/SwapDomainModule.kt b/features/swap/domain/src/main/java/com/tangem/feature/swap/domain/di/SwapDomainModule.kt index 0555412b3e..4d915395ad 100644 --- a/features/swap/domain/src/main/java/com/tangem/feature/swap/domain/di/SwapDomainModule.kt +++ b/features/swap/domain/src/main/java/com/tangem/feature/swap/domain/di/SwapDomainModule.kt @@ -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 { diff --git a/features/swap/domain/src/main/java/com/tangem/feature/swap/domain/di/SwapFeeQualifiers.kt b/features/swap/domain/src/main/java/com/tangem/feature/swap/domain/di/SwapFeeQualifiers.kt new file mode 100644 index 0000000000..d102d33af9 --- /dev/null +++ b/features/swap/domain/src/main/java/com/tangem/feature/swap/domain/di/SwapFeeQualifiers.kt @@ -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 \ No newline at end of file diff --git a/features/swap/domain/src/main/java/com/tangem/feature/swap/domain/fee/CexFeeResult.kt b/features/swap/domain/src/main/java/com/tangem/feature/swap/domain/fee/CexFeeResult.kt new file mode 100644 index 0000000000..3de142fe45 --- /dev/null +++ b/features/swap/domain/src/main/java/com/tangem/feature/swap/domain/fee/CexFeeResult.kt @@ -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, +) \ No newline at end of file diff --git a/features/swap/domain/src/main/java/com/tangem/feature/swap/domain/fee/CexSwapFeeCalculator.kt b/features/swap/domain/src/main/java/com/tangem/feature/swap/domain/fee/CexSwapFeeCalculator.kt new file mode 100644 index 0000000000..a352fd73a7 --- /dev/null +++ b/features/swap/domain/src/main/java/com/tangem/feature/swap/domain/fee/CexSwapFeeCalculator.kt @@ -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 = 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) + } +} \ No newline at end of file diff --git a/features/swap/domain/src/main/java/com/tangem/feature/swap/domain/fee/DexFeeResult.kt b/features/swap/domain/src/main/java/com/tangem/feature/swap/domain/fee/DexFeeResult.kt new file mode 100644 index 0000000000..b6d6f767e4 --- /dev/null +++ b/features/swap/domain/src/main/java/com/tangem/feature/swap/domain/fee/DexFeeResult.kt @@ -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?, +) \ No newline at end of file diff --git a/features/swap/domain/src/main/java/com/tangem/feature/swap/domain/fee/DexSwapFeeCalculator.kt b/features/swap/domain/src/main/java/com/tangem/feature/swap/domain/fee/DexSwapFeeCalculator.kt new file mode 100644 index 0000000000..1509a7338f --- /dev/null +++ b/features/swap/domain/src/main/java/com/tangem/feature/swap/domain/fee/DexSwapFeeCalculator.kt @@ -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 = 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 = 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 + } + } +} \ No newline at end of file diff --git a/features/swap/domain/src/main/java/com/tangem/feature/swap/domain/fee/PatchEthGasLimitForSwap.kt b/features/swap/domain/src/main/java/com/tangem/feature/swap/domain/fee/PatchEthGasLimitForSwap.kt new file mode 100644 index 0000000000..2a400b9c6b --- /dev/null +++ b/features/swap/domain/src/main/java/com/tangem/feature/swap/domain/fee/PatchEthGasLimitForSwap.kt @@ -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") + } +} \ No newline at end of file diff --git a/features/swap/domain/src/main/java/com/tangem/feature/swap/domain/fee/SwapFeeFactory.kt b/features/swap/domain/src/main/java/com/tangem/feature/swap/domain/fee/SwapFeeFactory.kt new file mode 100644 index 0000000000..cc59fd8930 --- /dev/null +++ b/features/swap/domain/src/main/java/com/tangem/feature/swap/domain/fee/SwapFeeFactory.kt @@ -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 + } +} \ No newline at end of file diff --git a/features/swap/domain/src/main/java/com/tangem/feature/swap/domain/fee/TransactionFeeResult.kt b/features/swap/domain/src/main/java/com/tangem/feature/swap/domain/fee/TransactionFeeResult.kt new file mode 100644 index 0000000000..e73623d8f4 --- /dev/null +++ b/features/swap/domain/src/main/java/com/tangem/feature/swap/domain/fee/TransactionFeeResult.kt @@ -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) + } +} \ No newline at end of file diff --git a/features/swap/domain/src/main/java/com/tangem/feature/swap/domain/models/ExpressDataError.kt b/features/swap/domain/src/main/java/com/tangem/feature/swap/domain/models/ExpressDataError.kt index 1f13d0910e..eea1938453 100644 --- a/features/swap/domain/src/main/java/com/tangem/feature/swap/domain/models/ExpressDataError.kt +++ b/features/swap/domain/src/main/java/com/tangem/feature/swap/domain/models/ExpressDataError.kt @@ -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() } \ No newline at end of file diff --git a/features/swap/domain/src/main/java/com/tangem/feature/swap/domain/models/domain/PreparedSwapConfigState.kt b/features/swap/domain/src/main/java/com/tangem/feature/swap/domain/models/domain/PreparedSwapConfigState.kt index 8f1eab0d73..0f1cd8a649 100644 --- a/features/swap/domain/src/main/java/com/tangem/feature/swap/domain/models/domain/PreparedSwapConfigState.kt +++ b/features/swap/domain/src/main/java/com/tangem/feature/swap/domain/models/domain/PreparedSwapConfigState.kt @@ -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 } \ No newline at end of file diff --git a/features/swap/domain/src/main/java/com/tangem/feature/swap/domain/models/domain/SwapFeeState.kt b/features/swap/domain/src/main/java/com/tangem/feature/swap/domain/models/domain/SwapFeeState.kt deleted file mode 100644 index b4ceb30a64..0000000000 --- a/features/swap/domain/src/main/java/com/tangem/feature/swap/domain/models/domain/SwapFeeState.kt +++ /dev/null @@ -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() -} \ No newline at end of file diff --git a/features/swap/domain/src/main/java/com/tangem/feature/swap/domain/models/ui/FeeBucket.kt b/features/swap/domain/src/main/java/com/tangem/feature/swap/domain/models/ui/FeeBucket.kt new file mode 100644 index 0000000000..e1f0089aa6 --- /dev/null +++ b/features/swap/domain/src/main/java/com/tangem/feature/swap/domain/models/ui/FeeBucket.kt @@ -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" + } +} \ No newline at end of file diff --git a/features/swap/domain/src/main/java/com/tangem/feature/swap/domain/models/ui/SwapFee.kt b/features/swap/domain/src/main/java/com/tangem/feature/swap/domain/models/ui/SwapFee.kt new file mode 100644 index 0000000000..8e158aa5d6 --- /dev/null +++ b/features/swap/domain/src/main/java/com/tangem/feature/swap/domain/models/ui/SwapFee.kt @@ -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, +) \ No newline at end of file diff --git a/features/swap/domain/src/main/java/com/tangem/feature/swap/domain/models/ui/SwapState.kt b/features/swap/domain/src/main/java/com/tangem/feature/swap/domain/models/ui/SwapState.kt index c98a1a7c48..dd33112779 100644 --- a/features/swap/domain/src/main/java/com/tangem/feature/swap/domain/models/ui/SwapState.kt +++ b/features/swap/domain/src/main/java/com/tangem/feature/swap/domain/models/ui/SwapState.kt @@ -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" - } -} \ No newline at end of file +) \ No newline at end of file diff --git a/features/swap/domain/src/main/java/com/tangem/feature/swap/domain/models/ui/TokensDataStateExpress.kt b/features/swap/domain/src/main/java/com/tangem/feature/swap/domain/models/ui/TokensDataStateExpress.kt deleted file mode 100644 index d2da881490..0000000000 --- a/features/swap/domain/src/main/java/com/tangem/feature/swap/domain/models/ui/TokensDataStateExpress.kt +++ /dev/null @@ -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, -) { - 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, - val unavailable: List, - val accountCurrencyList: List, - val isAfterSearch: Boolean, -) - -data class AccountSwapAvailability( - val account: Account, - val currencyList: List, -) - -data class AccountSwapCurrency( - val isAvailable: Boolean, - val account: Account, - val cryptoCurrencyStatus: CryptoCurrencyStatus, - val providers: List, -) \ No newline at end of file diff --git a/features/swap/domain/src/main/java/com/tangem/feature/swap/domain/transfer/SwapTransferInteractor.kt b/features/swap/domain/src/main/java/com/tangem/feature/swap/domain/transfer/SwapTransferInteractor.kt index 788080bc93..bd53ada8ec 100644 --- a/features/swap/domain/src/main/java/com/tangem/feature/swap/domain/transfer/SwapTransferInteractor.kt +++ b/features/swap/domain/src/main/java/com/tangem/feature/swap/domain/transfer/SwapTransferInteractor.kt @@ -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 + + suspend fun loadFeeExtended( + fromSwapCurrencyStatus: SwapCurrencyStatus, + toSwapCurrencyStatus: SwapCurrencyStatus, + fromTokenAmount: String, + ): Either + + suspend fun sendTransfer( + fromSwapCurrencyStatus: SwapCurrencyStatus, + toSwapCurrencyStatus: SwapCurrencyStatus, + fromTokenAmount: String, + fee: Fee, + transactionFeeResult: TransactionFeeResult, + ): Either } \ No newline at end of file diff --git a/features/swap/domain/src/main/java/com/tangem/feature/swap/domain/transfer/SwapTransferInteractorImpl.kt b/features/swap/domain/src/main/java/com/tangem/feature/swap/domain/transfer/SwapTransferInteractorImpl.kt index 1bdd520d50..9b26409e31 100644 --- a/features/swap/domain/src/main/java/com/tangem/feature/swap/domain/transfer/SwapTransferInteractorImpl.kt +++ b/features/swap/domain/src/main/java/com/tangem/feature/swap/domain/transfer/SwapTransferInteractorImpl.kt @@ -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 { + 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 { + 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 { + 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 { + 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 { + return GetFeeError.DataError(IllegalStateException(message)).left() + } + + private fun SwapCurrencyStatus.destinationAddress(): String? { + return status.value.networkAddress?.defaultAddress?.value + } } \ No newline at end of file diff --git a/features/swap/domain/src/test/kotlin/com/tangem/feature/swap/domain/SwapInteractorImplApplySwapFeeMatrixTest.kt b/features/swap/domain/src/test/kotlin/com/tangem/feature/swap/domain/SwapInteractorImplApplySwapFeeMatrixTest.kt new file mode 100644 index 0000000000..c36c3b6d0d --- /dev/null +++ b/features/swap/domain/src/test/kotlin/com/tangem/feature/swap/domain/SwapInteractorImplApplySwapFeeMatrixTest.kt @@ -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(relaxed = true) + val gaslessToken = mockk(relaxed = true) { + every { id } returns gaslessTokenId + } + val gaslessTokenStatus = mockk(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(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(relaxed = true) + val gaslessToken = mockk(relaxed = true) { + every { id } returns gaslessTokenId + every { name } returns "GasToken" + every { symbol } returns "GAS" + } + val gaslessTokenStatus = mockk(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(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(relaxed = true) + val gaslessToken = mockk(relaxed = true) { + every { id } returns gaslessTokenId + every { name } returns "GasToken" + every { symbol } returns "GAS" + } + val gaslessTokenStatus = mockk(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(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(relaxed = true) { + every { value } returns feeValue + } + val fee = mockk(relaxed = true) { + every { this@mockk.amount } returns amount + } + val coinCurrency = mockk(relaxed = true) + val feeTokenStatus = mockk(relaxed = true) { + every { currency } returns coinCurrency + } + return com.tangem.feature.swap.domain.models.ui.SwapFee( + fee = fee, + transactionFeeResult = TransactionFeeResult.Loaded(mockk(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(relaxed = true) { + every { value } returns feeValue + } + val fee = mockk(relaxed = true) { + every { this@mockk.amount } returns amount + } + return com.tangem.feature.swap.domain.models.ui.SwapFee( + fee = fee, + transactionFeeResult = TransactionFeeResult.Loaded(mockk(relaxed = true)), + selectedFeeToken = tokenStatus, + otherNativeFee = BigDecimal.ZERO, + feeBucket = FeeBucket.MARKET, + ) + } +} \ No newline at end of file diff --git a/features/swap/domain/src/test/kotlin/com/tangem/feature/swap/domain/SwapInteractorImplApplySwapFeeTest.kt b/features/swap/domain/src/test/kotlin/com/tangem/feature/swap/domain/SwapInteractorImplApplySwapFeeTest.kt new file mode 100644 index 0000000000..f608e724fa --- /dev/null +++ b/features/swap/domain/src/test/kotlin/com/tangem/feature/swap/domain/SwapInteractorImplApplySwapFeeTest.kt @@ -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(relaxed = true) { + every { value } returns feeValue + } + val fee = mockk(relaxed = true) { + every { this@mockk.amount } returns amount + } + val feeTokenStatus = mockk(relaxed = true) { + every { currency } returns buildCoinCurrency() + } + return SwapFee( + fee = fee, + transactionFeeResult = TransactionFeeResult.Loaded(mockk(relaxed = true)), + selectedFeeToken = feeTokenStatus, + otherNativeFee = otherNativeFee, + feeBucket = FeeBucket.MARKET, + ) + } +} \ No newline at end of file diff --git a/features/swap/domain/src/test/kotlin/com/tangem/feature/swap/domain/SwapInteractorImplFindBestQuoteTest.kt b/features/swap/domain/src/test/kotlin/com/tangem/feature/swap/domain/SwapInteractorImplFindBestQuoteTest.kt index b0241f7f9e..f2f4017539 100644 --- a/features/swap/domain/src/test/kotlin/com/tangem/feature/swap/domain/SwapInteractorImplFindBestQuoteTest.kt +++ b/features/swap/domain/src/test/kotlin/com/tangem/feature/swap/domain/SwapInteractorImplFindBestQuoteTest.kt @@ -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(relaxed = true).right() - coEvery { - getFeeUseCase.invoke(userWallet = any(), network = any(), transactionData = any()) - } returns mockk(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(), 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(), any()) } returns ByteArray(931) + io.mockk.mockkObject(SolanaTransactionHelper) + every { + SolanaTransactionHelper.removeSignaturesPlaceholders(any()) + } returns ByteArray(931) - val dexProvider = buildSwapProvider(ExchangeProviderType.DEX) - val coldWallet = mockk(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(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 diff --git a/features/swap/domain/src/test/kotlin/com/tangem/feature/swap/domain/SwapInteractorImplGetNativeTokenTest.kt b/features/swap/domain/src/test/kotlin/com/tangem/feature/swap/domain/SwapInteractorImplGetNativeTokenTest.kt deleted file mode 100644 index b624a079ab..0000000000 --- a/features/swap/domain/src/test/kotlin/com/tangem/feature/swap/domain/SwapInteractorImplGetNativeTokenTest.kt +++ /dev/null @@ -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(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(relaxed = true) { - every { network } returns mockk(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) - } -} \ No newline at end of file diff --git a/features/swap/domain/src/test/kotlin/com/tangem/feature/swap/domain/SwapInteractorImplLoadDexSwapDataNoFeeTest.kt b/features/swap/domain/src/test/kotlin/com/tangem/feature/swap/domain/SwapInteractorImplLoadDexSwapDataNoFeeTest.kt new file mode 100644 index 0000000000..b1af123e7b --- /dev/null +++ b/features/swap/domain/src/test/kotlin/com/tangem/feature/swap/domain/SwapInteractorImplLoadDexSwapDataNoFeeTest.kt @@ -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()) } + } +} \ No newline at end of file diff --git a/features/swap/domain/src/test/kotlin/com/tangem/feature/swap/domain/SwapInteractorImplLoadFeeTest.kt b/features/swap/domain/src/test/kotlin/com/tangem/feature/swap/domain/SwapInteractorImplLoadFeeTest.kt deleted file mode 100644 index d1f5800196..0000000000 --- a/features/swap/domain/src/test/kotlin/com/tangem/feature/swap/domain/SwapInteractorImplLoadFeeTest.kt +++ /dev/null @@ -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]): - * - 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]): - * - 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(relaxed = true) - val expectedFeeExtended = mockk(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(relaxed = true) - val capturedAmount = slot() - - 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(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(), - ) - } - } - } -} \ No newline at end of file diff --git a/features/swap/domain/src/test/kotlin/com/tangem/feature/swap/domain/SwapInteractorImplLoadSwapFeeTest.kt b/features/swap/domain/src/test/kotlin/com/tangem/feature/swap/domain/SwapInteractorImplLoadSwapFeeTest.kt new file mode 100644 index 0000000000..066fba5fe5 --- /dev/null +++ b/features/swap/domain/src/test/kotlin/com/tangem/feature/swap/domain/SwapInteractorImplLoadSwapFeeTest.kt @@ -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(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(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(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(relaxed = true) { + // Gasless picked native — feeTokenId points at the network's coin. + io.mockk.every { transactionFee } returns TransactionFee.Single( + normal = mockk(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(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(relaxed = true) { + io.mockk.every { currency } returns mockk(relaxed = true) + } + val extendedFee = mockk(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(relaxed = true) { + io.mockk.every { currency } returns mockk(relaxed = true) + } + val rawFee = TransactionFee.Single(normal = mockk(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(relaxed = true) { + io.mockk.every { currency } returns mockk(relaxed = true) + } + val rawFee = TransactionFee.Single(normal = mockk(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(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), + ) +} \ No newline at end of file diff --git a/features/swap/domain/src/test/kotlin/com/tangem/feature/swap/domain/SwapInteractorImplOnSwapTest.kt b/features/swap/domain/src/test/kotlin/com/tangem/feature/swap/domain/SwapInteractorImplOnSwapTest.kt deleted file mode 100644 index 131da6aa29..0000000000 --- a/features/swap/domain/src/test/kotlin/com/tangem/feature/swap/domain/SwapInteractorImplOnSwapTest.kt +++ /dev/null @@ -1,1034 +0,0 @@ -package com.tangem.feature.swap.domain - -import android.util.Base64 -import arrow.core.left -import arrow.core.right -import com.google.common.truth.Truth.assertThat -import com.tangem.blockchain.common.Blockchain -import com.tangem.blockchain.common.TransactionData -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.SendTransactionError -import com.tangem.domain.transaction.models.TransactionFeeExtended -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.IncludeFeeInAmount -import com.tangem.feature.swap.domain.models.domain.SwapDataModel -import com.tangem.feature.swap.domain.models.ui.* -import io.mockk.* -import kotlinx.coroutines.test.runTest -import org.junit.jupiter.api.* -import org.junit.jupiter.api.TestInstance.Lifecycle.PER_CLASS -import java.math.BigDecimal -import java.math.BigInteger - -@TestInstance(PER_CLASS) -internal class SwapInteractorImplOnSwapTest : SwapInteractorImplTestBase() { - - private val ethNetwork = Blockchain.Ethereum.toNetworkId() - private val solanaNetwork = Blockchain.Solana.toNetworkId() - - @BeforeEach - fun setupOnSwap() { - // Clear recorded calls so that coVerify(exactly = 1) counts only the current test's call. - clearMocks( - sendTransactionUseCase, - createTransactionUseCase, - createTransferTransactionUseCase, - createAndSendGaslessTransactionUseCase, - repository, - swapTransactionRepository, - answers = false, - ) - // isDemoCardUseCase should return false by default so the non-demo path is exercised. - // Individual tests that need demo mode override this. - every { isDemoCardUseCase(any()) } returns false - } - - // region — shared helpers - - /** - * Builds a SwapCurrencyStatus backed by an explicit UserWallet.Hot mock so that - * `userWallet is UserWallet.Cold` evaluates to false reliably. - */ - private fun buildHotSwapCurrencyStatus( - networkRawId: String = ethNetwork, - isCoin: Boolean = true, - ): SwapCurrencyStatus { - val hotWallet = mockk(relaxed = true) - return buildSwapCurrencyStatus(networkRawId = networkRawId, isCoin = isCoin).let { - SwapCurrencyStatus(userWallet = hotWallet, status = it.status, account = it.account) - } - } - - private fun buildCexSwapDataModel( - txTo: String = "0xCexAddress", - txId: String = "cex-tx-id", - txExtraId: String? = null, - externalTxUrl: String = "https://explorer.com/tx/123", - externalTxId: String = "ext-id-123", - toAmount: BigDecimal = BigDecimal("0.9"), - ): SwapDataModel = SwapDataModel( - toTokenAmount = SwapAmount(toAmount, 18), - transaction = ExpressTransactionModel.CEX( - fromAmount = SwapAmount(BigDecimal.ONE, 18), - toAmount = SwapAmount(toAmount, 18), - txValue = null, - txId = txId, - txTo = txTo, - txExtraId = txExtraId, - externalTxId = externalTxId, - externalTxUrl = externalTxUrl, - txExtraIdName = null, - ), - ) - - // endregion - - // ------------------------------------------------------------------------- - // Dispatcher Branches - // ------------------------------------------------------------------------- - - @Nested - inner class DispatcherBranches { - - @Test - fun `should return DemoMode for Cold card when isDemoCardUseCase returns true`() = runTest { - // Given - val coldWallet = mockk(relaxed = true) - every { isDemoCardUseCase(any()) } returns true - - val fromStatus = buildSwapCurrencyStatus(networkRawId = ethNetwork).let { - SwapCurrencyStatus(userWallet = coldWallet, status = it.status, account = it.account) - } - val toStatus = buildHotSwapCurrencyStatus() - val dexProvider = buildSwapProvider(ExchangeProviderType.DEX) - val swapData = buildSwapDataModelDex() - - // When - val result = sut.onSwap( - fromSwapCurrencyStatus = fromStatus, - toSwapCurrencyStatus = toStatus, - swapProvider = dexProvider, - swapData = swapData, - amountToSwap = "1.0", - includeFeeInAmount = IncludeFeeInAmount.Excluded, - fee = buildTxFee(), - expressOperationType = com.tangem.domain.express.models.ExpressOperationType.SWAP, - isTangemPayWithdrawal = false, - ) - - // Then - assertThat(result).isInstanceOf(SwapTransactionState.DemoMode::class.java) - coVerify(exactly = 0) { - createTransactionUseCase( - amount = any(), fee = any(), memo = any(), - destination = any(), userWalletId = any(), network = any(), - ) - } - coVerify(exactly = 0) { - createTransferTransactionUseCase( - amount = any(), fee = any(), memo = any(), - destination = any(), userWalletId = any(), network = any(), - ) - } - 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 route to onSwapCex and call getExchangeData for CEX provider`() = runTest { - // Given - val cexProvider = buildSwapProvider(ExchangeProviderType.CEX, providerId = "cex-route-id") - val fromStatus = buildHotSwapCurrencyStatus() - val toStatus = buildHotSwapCurrencyStatus() - val cexSwapData = buildCexSwapDataModel() - - coEvery { - repository.getExchangeData( - userWallet = any(), fromContractAddress = any(), fromNetwork = any(), - toContractAddress = any(), fromAddress = any(), toNetwork = any(), - fromAmount = any(), fromDecimals = any(), toDecimals = any(), - providerId = cexProvider.providerId, rateType = any(), toAddress = any(), - expressOperationType = any(), refundAddress = any(), - ) - } returns cexSwapData.right() - - val txDataMock = mockk(relaxed = true) { - every { extras } returns null - } - coEvery { - createTransferTransactionUseCase( - amount = any(), fee = any(), memo = any(), - destination = any(), userWalletId = any(), network = any(), - ) - } returns txDataMock.right() - - coEvery { - sendTransactionUseCase(txData = any(), userWallet = any(), network = any()) - } returns "0xhash".right() - - // When - sut.onSwap( - fromSwapCurrencyStatus = fromStatus, - toSwapCurrencyStatus = toStatus, - swapProvider = cexProvider, - swapData = null, - amountToSwap = "1.0", - includeFeeInAmount = IncludeFeeInAmount.Excluded, - fee = buildTxFee(), - expressOperationType = com.tangem.domain.express.models.ExpressOperationType.SWAP, - isTangemPayWithdrawal = false, - ) - - // Then - coVerify(exactly = 1) { - repository.getExchangeData( - userWallet = any(), fromContractAddress = any(), fromNetwork = any(), - toContractAddress = any(), fromAddress = any(), toNetwork = any(), - fromAmount = any(), fromDecimals = any(), toDecimals = any(), - providerId = cexProvider.providerId, rateType = any(), toAddress = any(), - expressOperationType = any(), refundAddress = any(), - ) - } - } - - @Test - fun `should return UnknownError for DEX non-Solana when fee is null`() = runTest { - // Given - val dexProvider = buildSwapProvider(ExchangeProviderType.DEX) - val fromStatus = buildHotSwapCurrencyStatus() - val toStatus = buildHotSwapCurrencyStatus() - val swapData = buildSwapDataModelDex() - - // When - val result = sut.onSwap( - fromSwapCurrencyStatus = fromStatus, - toSwapCurrencyStatus = toStatus, - swapProvider = dexProvider, - swapData = swapData, - amountToSwap = "1.0", - includeFeeInAmount = IncludeFeeInAmount.Excluded, - fee = null, - expressOperationType = com.tangem.domain.express.models.ExpressOperationType.SWAP, - isTangemPayWithdrawal = false, - ) - - // Then - assertThat(result).isInstanceOf(SwapTransactionState.Error.UnknownError::class.java) - coVerify(exactly = 0) { - createTransactionUseCase( - amount = any(), fee = any(), memo = any(), - destination = any(), userWalletId = any(), network = any(), - ) - } - } - - @Test - fun `should route to onSwapDex for DEX_BRIDGE non-Solana with valid fee`() = runTest { - // Given - val dexBridgeProvider = buildSwapProvider(ExchangeProviderType.DEX_BRIDGE) - val fromStatus = buildHotSwapCurrencyStatus() - val toStatus = buildHotSwapCurrencyStatus() - val swapData = buildSwapDataModelDex(txValue = "1000000000000000") - val fee = buildTxFee() - - every { - createTransactionExtrasUseCase.invoke( - data = any(), network = any(), gasLimit = any(), - ) - } returns mockk(relaxed = true).right() - - val txDataMock = mockk(relaxed = true) - coEvery { - createTransactionUseCase( - amount = any(), fee = any(), memo = any(), - destination = any(), userWalletId = any(), network = any(), - txExtras = any(), - ) - } returns txDataMock.right() - - coEvery { - sendTransactionUseCase(txData = any(), userWallet = any(), network = any()) - } returns "0xhash-bridge".right() - - // When - sut.onSwap( - fromSwapCurrencyStatus = fromStatus, - toSwapCurrencyStatus = toStatus, - swapProvider = dexBridgeProvider, - swapData = swapData, - amountToSwap = "1.0", - includeFeeInAmount = IncludeFeeInAmount.Excluded, - fee = fee, - expressOperationType = com.tangem.domain.express.models.ExpressOperationType.SWAP, - isTangemPayWithdrawal = false, - ) - - // Then - coVerify(exactly = 1) { - createTransactionUseCase( - amount = any(), fee = any(), memo = any(), - destination = any(), userWalletId = any(), network = any(), - txExtras = any(), - ) - } - } - - @Test - fun `should route to onSwapSolanaDex for DEX Solana without calling createTransactionUseCase`() = runTest { - // Given - mockkStatic(Base64::class) - every { Base64.decode(any(), any()) } returns ByteArray(100) - - val dexProvider = buildSwapProvider(ExchangeProviderType.DEX) - val fromStatus = buildHotSwapCurrencyStatus(networkRawId = solanaNetwork) - val toStatus = buildHotSwapCurrencyStatus(networkRawId = solanaNetwork) - val swapData = buildSwapDataModelDex(txData = "dGVzdA==") - - coEvery { - sendTransactionUseCase(txData = any(), userWallet = any(), network = any()) - } returns "0xsolana-hash".right() - - // When - sut.onSwap( - fromSwapCurrencyStatus = fromStatus, - toSwapCurrencyStatus = toStatus, - swapProvider = dexProvider, - swapData = swapData, - amountToSwap = "1.0", - includeFeeInAmount = IncludeFeeInAmount.Excluded, - fee = buildTxFee(), - expressOperationType = com.tangem.domain.express.models.ExpressOperationType.SWAP, - isTangemPayWithdrawal = false, - ) - - // Then - coVerify(exactly = 0) { - createTransactionUseCase( - amount = any(), fee = any(), memo = any(), - destination = any(), userWalletId = any(), network = any(), - ) - } - - unmockkStatic(Base64::class) - } - } - - // ------------------------------------------------------------------------- - // OnSwapDex - // ------------------------------------------------------------------------- - - @Nested - inner class OnSwapDex { - - @Test - fun `should return TxSent and call exchangeSent and storeTransaction on happy path`() = runTest { - // Given - val dexProvider = buildSwapProvider(ExchangeProviderType.DEX) - val fromStatus = buildHotSwapCurrencyStatus() - val toStatus = buildHotSwapCurrencyStatus() - val swapData = buildSwapDataModelDex(txValue = "1000000000000000") - val fee = buildTxFee() - - every { - createTransactionExtrasUseCase.invoke(data = any(), network = any(), gasLimit = any()) - } returns mockk(relaxed = true).right() - - val txDataMock = mockk(relaxed = true) - coEvery { - createTransactionUseCase( - amount = any(), fee = any(), memo = any(), - destination = any(), userWalletId = any(), network = any(), - txExtras = any(), - ) - } returns txDataMock.right() - - coEvery { - sendTransactionUseCase(txData = any(), userWallet = any(), network = any()) - } returns "0xdex-hash".right() - - every { amountFormatter.formatSwapAmountToUI(any(), any()) } returns "1.0 ETH" - - // When - val result = sut.onSwap( - fromSwapCurrencyStatus = fromStatus, - toSwapCurrencyStatus = toStatus, - swapProvider = dexProvider, - swapData = swapData, - amountToSwap = "1.0", - includeFeeInAmount = IncludeFeeInAmount.Excluded, - fee = fee, - expressOperationType = com.tangem.domain.express.models.ExpressOperationType.SWAP, - isTangemPayWithdrawal = false, - ) - - // Then - assertThat(result).isInstanceOf(SwapTransactionState.TxSent::class.java) - val txSent = result as SwapTransactionState.TxSent - assertThat(txSent.txHash).isEqualTo("0xdex-hash") - - coVerify(exactly = 1) { - repository.exchangeSent( - userWallet = any(), txId = any(), fromNetwork = any(), - fromAddress = any(), payInAddress = any(), - txHash = "0xdex-hash", payInExtraId = any(), - ) - } - coVerify(exactly = 1) { - swapTransactionRepository.storeTransaction( - fromUserWalletId = any(), toUserWalletId = any(), - fromCryptoCurrency = any(), toCryptoCurrency = any(), - fromAccount = any(), toAccount = any(), transaction = any(), - ) - } - } - - @Test - fun `should return UnknownError and not send when createTransactionUseCase fails`() = runTest { - // Given - val dexProvider = buildSwapProvider(ExchangeProviderType.DEX) - val fromStatus = buildHotSwapCurrencyStatus() - val toStatus = buildHotSwapCurrencyStatus() - val swapData = buildSwapDataModelDex(txValue = "1000000000000000") - val fee = buildTxFee() - - every { - createTransactionExtrasUseCase.invoke(data = any(), network = any(), gasLimit = any()) - } returns mockk(relaxed = true).right() - - coEvery { - createTransactionUseCase( - amount = any(), fee = any(), memo = any(), - destination = any(), userWalletId = any(), network = any(), - txExtras = any(), - ) - } returns RuntimeException("create tx failed").left() - - // When - val result = sut.onSwap( - fromSwapCurrencyStatus = fromStatus, - toSwapCurrencyStatus = toStatus, - swapProvider = dexProvider, - swapData = swapData, - amountToSwap = "1.0", - includeFeeInAmount = IncludeFeeInAmount.Excluded, - fee = fee, - expressOperationType = com.tangem.domain.express.models.ExpressOperationType.SWAP, - isTangemPayWithdrawal = false, - ) - - // Then - assertThat(result).isInstanceOf(SwapTransactionState.Error.UnknownError::class.java) - coVerify(exactly = 0) { - sendTransactionUseCase(txData = any(), userWallet = any(), network = any()) - } - } - - @Test - fun `should return TransactionError and not call exchangeSent when sendTransactionUseCase fails`() = runTest { - // Given - val dexProvider = buildSwapProvider(ExchangeProviderType.DEX) - val fromStatus = buildHotSwapCurrencyStatus() - val toStatus = buildHotSwapCurrencyStatus() - val swapData = buildSwapDataModelDex(txValue = "1000000000000000") - val fee = buildTxFee() - val sendError = SendTransactionError.NetworkError(message = "timeout", code = "503") - - every { - createTransactionExtrasUseCase.invoke(data = any(), network = any(), gasLimit = any()) - } returns mockk(relaxed = true).right() - - val txDataMock = mockk(relaxed = true) - coEvery { - createTransactionUseCase( - amount = any(), fee = any(), memo = any(), - destination = any(), userWalletId = any(), network = any(), - txExtras = any(), - ) - } returns txDataMock.right() - - coEvery { - sendTransactionUseCase(txData = any(), userWallet = any(), network = any()) - } returns sendError.left() - - // When - val result = sut.onSwap( - fromSwapCurrencyStatus = fromStatus, - toSwapCurrencyStatus = toStatus, - swapProvider = dexProvider, - swapData = swapData, - amountToSwap = "1.0", - includeFeeInAmount = IncludeFeeInAmount.Excluded, - fee = fee, - expressOperationType = com.tangem.domain.express.models.ExpressOperationType.SWAP, - isTangemPayWithdrawal = false, - ) - - // Then - assertThat(result).isInstanceOf(SwapTransactionState.Error.TransactionError::class.java) - val txError = result as SwapTransactionState.Error.TransactionError - assertThat(txError.error).isEqualTo(sendError) - - coVerify(exactly = 0) { - repository.exchangeSent(any(), any(), any(), any(), any(), any(), any()) - } - coVerify(exactly = 0) { - swapTransactionRepository.storeTransaction(any(), any(), any(), any(), any(), any(), any()) - } - } - } - - // ------------------------------------------------------------------------- - // OnSwapSolanaDex - // ------------------------------------------------------------------------- - - @Nested - inner class OnSwapSolanaDex { - - @AfterEach - fun tearDown() { - unmockkStatic(Base64::class) - } - - @Test - fun `should return TxSent and call exchangeSent and storeTransaction on happy path`() = runTest { - // Given - mockkStatic(Base64::class) - every { Base64.decode(any(), any()) } returns ByteArray(100) - - val dexProvider = buildSwapProvider(ExchangeProviderType.DEX) - val fromStatus = buildHotSwapCurrencyStatus(networkRawId = solanaNetwork) - val toStatus = buildHotSwapCurrencyStatus(networkRawId = solanaNetwork) - val swapData = buildSwapDataModelDex(txData = "dGVzdA==") - - coEvery { - sendTransactionUseCase(txData = any(), userWallet = any(), network = any()) - } returns "0xsolana-hash".right() - - every { amountFormatter.formatSwapAmountToUI(any(), any()) } returns "1.0 SOL" - - // When - val result = sut.onSwap( - fromSwapCurrencyStatus = fromStatus, - toSwapCurrencyStatus = toStatus, - swapProvider = dexProvider, - swapData = swapData, - amountToSwap = "1.0", - includeFeeInAmount = IncludeFeeInAmount.Excluded, - fee = null, - expressOperationType = com.tangem.domain.express.models.ExpressOperationType.SWAP, - isTangemPayWithdrawal = false, - ) - - // Then - assertThat(result).isInstanceOf(SwapTransactionState.TxSent::class.java) - val txSent = result as SwapTransactionState.TxSent - assertThat(txSent.txHash).isEqualTo("0xsolana-hash") - - coVerify(exactly = 1) { - repository.exchangeSent( - userWallet = any(), txId = any(), fromNetwork = any(), - fromAddress = any(), payInAddress = any(), - txHash = "0xsolana-hash", payInExtraId = any(), - ) - } - coVerify(exactly = 1) { - swapTransactionRepository.storeTransaction( - fromUserWalletId = any(), toUserWalletId = any(), - fromCryptoCurrency = any(), toCryptoCurrency = any(), - fromAccount = any(), toAccount = any(), transaction = any(), - ) - } - } - - @Test - fun `should return TransactionError when sendTransactionUseCase fails on Solana path`() = runTest { - // Given - mockkStatic(Base64::class) - every { Base64.decode(any(), any()) } returns ByteArray(100) - - val dexProvider = buildSwapProvider(ExchangeProviderType.DEX) - val fromStatus = buildHotSwapCurrencyStatus(networkRawId = solanaNetwork) - val toStatus = buildHotSwapCurrencyStatus(networkRawId = solanaNetwork) - val swapData = buildSwapDataModelDex(txData = "dGVzdA==") - val sendError = SendTransactionError.UserCancelledError - - coEvery { - sendTransactionUseCase(txData = any(), userWallet = any(), network = any()) - } returns sendError.left() - - // When - val result = sut.onSwap( - fromSwapCurrencyStatus = fromStatus, - toSwapCurrencyStatus = toStatus, - swapProvider = dexProvider, - swapData = swapData, - amountToSwap = "1.0", - includeFeeInAmount = IncludeFeeInAmount.Excluded, - fee = null, - expressOperationType = com.tangem.domain.express.models.ExpressOperationType.SWAP, - isTangemPayWithdrawal = false, - ) - - // Then - assertThat(result).isInstanceOf(SwapTransactionState.Error.TransactionError::class.java) - val txError = result as SwapTransactionState.Error.TransactionError - assertThat(txError.error).isEqualTo(sendError) - } - } - - // ------------------------------------------------------------------------- - // OnSwapCex - // ------------------------------------------------------------------------- - - @Nested - inner class OnSwapCex { - - private val cexProvider = buildSwapProvider(ExchangeProviderType.CEX, providerId = "cex-id") - - // Both from and to use Hot wallets to avoid spurious is-Cold checks - private val fromStatus = buildHotSwapCurrencyStatus() - private val toStatus = buildHotSwapCurrencyStatus() - - private fun stubGetExchangeData(result: SwapDataModel) { - 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 result.right() - } - - private fun stubCreateTransferTx(txDataMock: TransactionData.Uncompiled = mockk(relaxed = true)) { - coEvery { - createTransferTransactionUseCase( - amount = any(), fee = any(), memo = any(), - destination = any(), userWalletId = any(), network = any(), - ) - } returns txDataMock.right() - } - - private suspend fun callOnSwap( - fee: TxFee? = buildTxFee(), - isTangemPayWithdrawal: Boolean = false, - ) = sut.onSwap( - fromSwapCurrencyStatus = fromStatus, - toSwapCurrencyStatus = toStatus, - swapProvider = cexProvider, - swapData = null, - amountToSwap = "1.0", - includeFeeInAmount = IncludeFeeInAmount.Excluded, - fee = fee, - expressOperationType = com.tangem.domain.express.models.ExpressOperationType.SWAP, - isTangemPayWithdrawal = isTangemPayWithdrawal, - ) - - @Test - fun `should return ExpressError when getExchangeData fails`() = runTest { - // Given - val expressError = ExpressDataError.UnknownError - 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 expressError.left() - - // When - val result = callOnSwap() - - // Then - assertThat(result).isInstanceOf(SwapTransactionState.Error.ExpressError::class.java) - val error = result as SwapTransactionState.Error.ExpressError - assertThat(error.error).isEqualTo(expressError) - - coVerify(exactly = 0) { - createTransferTransactionUseCase( - amount = any(), fee = any(), memo = any(), - destination = any(), userWalletId = any(), network = any(), - ) - } - } - - @Test - fun `should return UnknownError when getExchangeData returns DEX transaction type`() = runTest { - // Given — DEX-typed SwapDataModel where CEX path expects CEX type - val dexSwapData = buildSwapDataModelDex() - 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 dexSwapData.right() - - // When - val result = callOnSwap() - - // Then — cast to CEX returns null → UnknownError - assertThat(result).isInstanceOf(SwapTransactionState.Error.UnknownError::class.java) - } - - @Test - fun `should return TangemPayWithdrawalData without sending when isTangemPayWithdrawal is true`() = runTest { - // Given - val cexSwapData = buildCexSwapDataModel(txTo = "0xCexDepositAddress") - stubGetExchangeData(cexSwapData) - every { amountFormatter.formatSwapAmountToUI(any(), any()) } returns "1.0 ETH" - - // When - val result = callOnSwap(isTangemPayWithdrawal = true) - - // Then - assertThat(result).isInstanceOf(SwapTransactionState.TangemPayWithdrawalData::class.java) - val withdrawalData = result as SwapTransactionState.TangemPayWithdrawalData - assertThat(withdrawalData.cexAddress).isEqualTo("0xCexDepositAddress") - assertThat(withdrawalData.storeData).isNotNull() - assertThat(withdrawalData.exchangeData).isNotNull() - - coVerify(exactly = 0) { - sendTransactionUseCase(txData = any(), userWallet = any(), network = any()) - } - coVerify(exactly = 0) { - createAndSendGaslessTransactionUseCase.invoke( - transactionData = any(), userWallet = any(), fee = any(), - ) - } - } - - @Test - fun `should return UnknownError for Cold demo card checked inside onSwapCex after getExchangeData`() = runTest { - // Given - // This demo check is at line ~818 of SwapInteractorImpl, AFTER getExchangeData succeeds. - // The dispatcher-level check is bypassed by returning false on the first call. - val coldWallet = mockk(relaxed = true) - - // First call → false (dispatcher check passes), second call → true (onSwapCex internal check) - every { isDemoCardUseCase(any()) } returnsMany listOf(false, true) - - val fromStatusCold = buildSwapCurrencyStatus(networkRawId = ethNetwork).let { - SwapCurrencyStatus(userWallet = coldWallet, status = it.status, account = it.account) - } - val cexSwapData = buildCexSwapDataModel() - 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 cexSwapData.right() - - // When - val result = sut.onSwap( - fromSwapCurrencyStatus = fromStatusCold, - toSwapCurrencyStatus = toStatus, - swapProvider = cexProvider, - swapData = null, - amountToSwap = "1.0", - includeFeeInAmount = IncludeFeeInAmount.Excluded, - fee = buildTxFee(), - expressOperationType = com.tangem.domain.express.models.ExpressOperationType.SWAP, - isTangemPayWithdrawal = false, - ) - - // Then - assertThat(result).isInstanceOf(SwapTransactionState.Error.UnknownError::class.java) - } - - @Test - fun `should return UnknownError when createTransferTransactionUseCase fails`() = runTest { - // Given - val cexSwapData = buildCexSwapDataModel() - stubGetExchangeData(cexSwapData) - coEvery { - createTransferTransactionUseCase( - amount = any(), fee = any(), memo = any(), - destination = any(), userWalletId = any(), network = any(), - ) - } returns RuntimeException("create transfer failed").left() - - // When - val result = callOnSwap() - - // Then - assertThat(result).isInstanceOf(SwapTransactionState.Error.UnknownError::class.java) - } - - @Test - fun `should return UnknownError when txData extras is null but txExtraId is present`() = runTest { - // Given - val cexSwapData = buildCexSwapDataModel(txExtraId = "extra-id-required") - stubGetExchangeData(cexSwapData) - - val txDataMock = mockk(relaxed = true) { - every { extras } returns null - } - stubCreateTransferTx(txDataMock) - - // When - val result = callOnSwap() - - // Then — extras == null AND txExtraId != null → UnknownError - assertThat(result).isInstanceOf(SwapTransactionState.Error.UnknownError::class.java) - } - - @Test - fun `should invoke createAndSendGaslessTransactionUseCase when FeeComponent with Token and LoadedExtended`() = - runTest { - // Given - val cexSwapData = buildCexSwapDataModel() - stubGetExchangeData(cexSwapData) - - val txDataMock = mockk(relaxed = true) { - every { extras } returns null - } - stubCreateTransferTx(txDataMock) - - val tokenCurrencyStatus = buildSwapCurrencyStatus(networkRawId = ethNetwork, isCoin = false) - val extendedFee = mockk(relaxed = true) - val gaslessFee = TxFee.FeeComponent( - fee = mockk(relaxed = true), - transactionFeeResult = TransactionFeeResult.LoadedExtended(extendedFee), - selectedToken = tokenCurrencyStatus.status, - ) - - coEvery { - createAndSendGaslessTransactionUseCase.invoke( - transactionData = any(), userWallet = any(), fee = any(), - ) - } returns "0xgasless-hash".right() - - // When - val result = sut.onSwap( - fromSwapCurrencyStatus = fromStatus, - toSwapCurrencyStatus = toStatus, - swapProvider = cexProvider, - swapData = null, - amountToSwap = "1.0", - includeFeeInAmount = IncludeFeeInAmount.Excluded, - fee = gaslessFee, - expressOperationType = com.tangem.domain.express.models.ExpressOperationType.SWAP, - isTangemPayWithdrawal = false, - ) - - // Then - coVerify(exactly = 1) { - createAndSendGaslessTransactionUseCase.invoke( - transactionData = any(), userWallet = any(), fee = extendedFee, - ) - } - coVerify(exactly = 0) { - sendTransactionUseCase(txData = any(), userWallet = any(), network = any()) - } - assertThat(result).isInstanceOf(SwapTransactionState.TxSent::class.java) - } - - @Test - fun `should invoke sendTransactionUseCase when FeeComponent but selectedToken is null`() = runTest { - // Given - val cexSwapData = buildCexSwapDataModel() - stubGetExchangeData(cexSwapData) - - val txDataMock = mockk(relaxed = true) { - every { extras } returns null - } - stubCreateTransferTx(txDataMock) - - val feeNoToken = TxFee.FeeComponent( - fee = mockk(relaxed = true), - transactionFeeResult = TransactionFeeResult.Loaded(mockk(relaxed = true)), - selectedToken = null, - ) - - coEvery { - sendTransactionUseCase(txData = any(), userWallet = any(), network = any()) - } returns "0xhash-notgasless".right() - - // When - sut.onSwap( - fromSwapCurrencyStatus = fromStatus, - toSwapCurrencyStatus = toStatus, - swapProvider = cexProvider, - swapData = null, - amountToSwap = "1.0", - includeFeeInAmount = IncludeFeeInAmount.Excluded, - fee = feeNoToken, - expressOperationType = com.tangem.domain.express.models.ExpressOperationType.SWAP, - isTangemPayWithdrawal = false, - ) - - // Then - coVerify(exactly = 1) { - sendTransactionUseCase(txData = any(), userWallet = any(), network = any()) - } - coVerify(exactly = 0) { - createAndSendGaslessTransactionUseCase.invoke(any(), any(), any()) - } - } - - @Test - fun `should invoke sendTransactionUseCase for Legacy fee`() = runTest { - // Given - val cexSwapData = buildCexSwapDataModel() - stubGetExchangeData(cexSwapData) - val txDataMock = mockk(relaxed = true) { - every { extras } returns null - } - stubCreateTransferTx(txDataMock) - - val legacyFee = buildTxFeeLegacy() - coEvery { - sendTransactionUseCase(txData = any(), userWallet = any(), network = any()) - } returns "0xlegacy-hash".right() - - // When - sut.onSwap( - fromSwapCurrencyStatus = fromStatus, - toSwapCurrencyStatus = toStatus, - swapProvider = cexProvider, - swapData = null, - amountToSwap = "1.0", - includeFeeInAmount = IncludeFeeInAmount.Excluded, - fee = legacyFee, - expressOperationType = com.tangem.domain.express.models.ExpressOperationType.SWAP, - isTangemPayWithdrawal = false, - ) - - // Then - coVerify(exactly = 1) { - sendTransactionUseCase(txData = any(), userWallet = any(), network = any()) - } - coVerify(exactly = 0) { - createAndSendGaslessTransactionUseCase.invoke(any(), any(), any()) - } - } - - @Test - fun `should return TxSent and call all three side effects on CEX send success`() = runTest { - // Given - val cexSwapData = buildCexSwapDataModel() - stubGetExchangeData(cexSwapData) - val txDataMock = mockk(relaxed = true) { - every { extras } returns null - } - stubCreateTransferTx(txDataMock) - - coEvery { - sendTransactionUseCase(txData = any(), userWallet = any(), network = any()) - } returns "0xcex-hash".right() - - every { amountFormatter.formatSwapAmountToUI(any(), any()) } returns "1.0 ETH" - - // When - val result = callOnSwap() - - // Then - assertThat(result).isInstanceOf(SwapTransactionState.TxSent::class.java) - val txSent = result as SwapTransactionState.TxSent - assertThat(txSent.txHash).isEqualTo("0xcex-hash") - - coVerify(exactly = 1) { - repository.exchangeSent( - userWallet = any(), txId = any(), fromNetwork = any(), - fromAddress = any(), payInAddress = any(), - txHash = "0xcex-hash", payInExtraId = any(), - ) - } - coVerify(exactly = 1) { - swapTransactionRepository.storeTransaction( - fromUserWalletId = any(), toUserWalletId = any(), - fromCryptoCurrency = any(), toCryptoCurrency = any(), - fromAccount = any(), toAccount = any(), transaction = any(), - ) - } - coVerify(exactly = 1) { - swapTransactionRepository.storeLastSwappedCryptoCurrencyId( - userWalletId = any(), cryptoCurrencyId = any(), - ) - } - } - - @Test - fun `should return TransactionError when CEX send fails`() = runTest { - // Given - val cexSwapData = buildCexSwapDataModel() - stubGetExchangeData(cexSwapData) - val txDataMock = mockk(relaxed = true) { - every { extras } returns null - } - stubCreateTransferTx(txDataMock) - - val sendError = SendTransactionError.DataError("connection reset") - coEvery { - sendTransactionUseCase(txData = any(), userWallet = any(), network = any()) - } returns sendError.left() - - // When - val result = callOnSwap() - - // Then - assertThat(result).isInstanceOf(SwapTransactionState.Error.TransactionError::class.java) - val txError = result as SwapTransactionState.Error.TransactionError - assertThat(txError.error).isEqualTo(sendError) - } - } -} - -// region — file-private builders - -private fun buildSwapDataModelDex( - txData: String = "dGVzdA==", - txValue: String? = "0", - toAmount: BigDecimal = BigDecimal("0.5"), -): SwapDataModel = SwapDataModel( - toTokenAmount = SwapAmount(toAmount, 18), - transaction = ExpressTransactionModel.DEX( - fromAmount = SwapAmount(BigDecimal.ONE, 18), - toAmount = SwapAmount(toAmount, 18), - txValue = txValue, - txId = "tx-id-123", - txTo = "0xRecipient", - txExtraId = null, - txFrom = "0xSender", - txData = txData, - otherNativeFeeWei = null, - gas = BigInteger.valueOf(21_000L), - ), -) - -private fun buildTxFeeLegacy( - feeValue: BigDecimal = BigDecimal("0.001"), -): TxFee.Legacy = TxFee.Legacy( - feeValue = feeValue, - feeFiatFormatted = "$0.01", - feeCryptoFormatted = "0.001 ETH", - feeIncludeOtherNativeFee = feeValue, - feeFiatFormattedWithNative = "$0.01", - feeCryptoFormattedWithNative = "0.001 ETH", - cryptoSymbol = "ETH", - feeType = FeeType.NORMAL, - fee = mockk(relaxed = true), -) - -// endregion \ No newline at end of file diff --git a/features/swap/domain/src/test/kotlin/com/tangem/feature/swap/domain/SwapInteractorImplTestBase.kt b/features/swap/domain/src/test/kotlin/com/tangem/feature/swap/domain/SwapInteractorImplTestBase.kt index ba41e6a388..71f8911ed2 100644 --- a/features/swap/domain/src/test/kotlin/com/tangem/feature/swap/domain/SwapInteractorImplTestBase.kt +++ b/features/swap/domain/src/test/kotlin/com/tangem/feature/swap/domain/SwapInteractorImplTestBase.kt @@ -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(relaxed = true) { every { value } returns feeValue } val fee = mockk(relaxed = true) { every { this@mockk.amount } returns amount } - return TxFee.FeeComponent( + return SwapFee( fee = fee, transactionFeeResult = TransactionFeeResult.Loaded( fee = mockk(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. */ diff --git a/features/swap/domain/src/test/kotlin/com/tangem/feature/swap/domain/fee/CexSwapFeeCalculatorTest.kt b/features/swap/domain/src/test/kotlin/com/tangem/feature/swap/domain/fee/CexSwapFeeCalculatorTest.kt new file mode 100644 index 0000000000..6d4e06580a --- /dev/null +++ b/features/swap/domain/src/test/kotlin/com/tangem/feature/swap/domain/fee/CexSwapFeeCalculatorTest.kt @@ -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(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(relaxed = true) + val tokenStatus = mockk(relaxed = true) { + every { currency } returns tokenCurrency + } + val expected = mockk(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(relaxed = true) + val coinStatus = mockk(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(relaxed = true) + val coinStatus = mockk(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(relaxed = true) + val coinStatus = mockk(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(relaxed = true) + val coinStatus = mockk(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(relaxed = true) + val expected = mockk(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, + ) + } + } +} \ No newline at end of file diff --git a/features/swap/domain/src/test/kotlin/com/tangem/feature/swap/domain/fee/DexSwapFeeCalculatorTest.kt b/features/swap/domain/src/test/kotlin/com/tangem/feature/swap/domain/fee/DexSwapFeeCalculatorTest.kt new file mode 100644 index 0000000000..bbd55adec7 --- /dev/null +++ b/features/swap/domain/src/test/kotlin/com/tangem/feature/swap/domain/fee/DexSwapFeeCalculatorTest.kt @@ -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(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() + coEvery { + getFeeUseCase.invoke( + userWallet = any(), + network = any(), + transactionData = capture(capturedTxData), + ) + } returns mockk(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(), + ) + } + } + + // ------------------------------------------------------------------------- + // 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(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(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(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(), 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() + 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(), any()) } returns oversizedBytes + mockkObject(SolanaTransactionHelper) + every { SolanaTransactionHelper.removeSignaturesPlaceholders(any()) } returns oversizedBytes + + val coldWallet = mockk(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(), 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, + ) +} \ No newline at end of file diff --git a/features/swap/domain/src/test/kotlin/com/tangem/feature/swap/domain/fee/PatchEthGasLimitForSwapTest.kt b/features/swap/domain/src/test/kotlin/com/tangem/feature/swap/domain/fee/PatchEthGasLimitForSwapTest.kt new file mode 100644 index 0000000000..0d6d739ce8 --- /dev/null +++ b/features/swap/domain/src/test/kotlin/com/tangem/feature/swap/domain/fee/PatchEthGasLimitForSwapTest.kt @@ -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 { + 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, + ) +} \ No newline at end of file diff --git a/features/swap/domain/src/test/kotlin/com/tangem/feature/swap/domain/fee/SwapFeeFactoryTest.kt b/features/swap/domain/src/test/kotlin/com/tangem/feature/swap/domain/fee/SwapFeeFactoryTest.kt new file mode 100644 index 0000000000..d03319e3e2 --- /dev/null +++ b/features/swap/domain/src/test/kotlin/com/tangem/feature/swap/domain/fee/SwapFeeFactoryTest.kt @@ -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(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(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), + ) +} \ No newline at end of file diff --git a/features/swap/domain/src/test/kotlin/com/tangem/feature/swap/domain/transfer/SwapTransferInteractorImplTest.kt b/features/swap/domain/src/test/kotlin/com/tangem/feature/swap/domain/transfer/SwapTransferInteractorImplTest.kt index 24e768bb02..5252bd405d 100644 --- a/features/swap/domain/src/test/kotlin/com/tangem/feature/swap/domain/transfer/SwapTransferInteractorImplTest.kt +++ b/features/swap/domain/src/test/kotlin/com/tangem/feature/swap/domain/transfer/SwapTransferInteractorImplTest.kt @@ -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") diff --git a/features/swap/impl/src/main/java/com/tangem/feature/swap/DefaultSwapComponent.kt b/features/swap/impl/src/main/java/com/tangem/feature/swap/DefaultSwapComponent.kt index bced0cb1ac..52c1cd0ccd 100644 --- a/features/swap/impl/src/main/java/com/tangem/feature/swap/DefaultSwapComponent.kt +++ b/features/swap/impl/src/main/java/com/tangem/feature/swap/DefaultSwapComponent.kt @@ -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 } } diff --git a/features/swap/impl/src/main/java/com/tangem/feature/swap/analytics/SwapEvents.kt b/features/swap/impl/src/main/java/com/tangem/feature/swap/analytics/SwapEvents.kt index 54c93824d2..9a5ad696ea 100644 --- a/features/swap/impl/src/main/java/com/tangem/feature/swap/analytics/SwapEvents.kt +++ b/features/swap/impl/src/main/java/com/tangem/feature/swap/analytics/SwapEvents.kt @@ -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( diff --git a/features/swap/impl/src/main/java/com/tangem/feature/swap/converters/AccountTokenItemConverter.kt b/features/swap/impl/src/main/java/com/tangem/feature/swap/converters/AccountTokenItemConverter.kt deleted file mode 100644 index 99118c8304..0000000000 --- a/features/swap/impl/src/main/java/com/tangem/feature/swap/converters/AccountTokenItemConverter.kt +++ /dev/null @@ -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, - private val onTokenItemClick: (Account, CryptoCurrencyStatus) -> Unit, - private val onAccountItemClick: (Account) -> Unit, -) : Converter { - - 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 - } - } -} \ No newline at end of file diff --git a/features/swap/impl/src/main/java/com/tangem/feature/swap/model/SwapModel.kt b/features/swap/impl/src/main/java/com/tangem/feature/swap/model/SwapModel.kt index 55defa360f..629e7eea51 100644 --- a/features/swap/impl/src/main/java/com/tangem/feature/swap/model/SwapModel.kt +++ b/features/swap/impl/src/main/java/com/tangem/feature/swap/model/SwapModel.kt @@ -9,8 +9,9 @@ import arrow.core.getOrElse import com.arkivanov.decompose.router.slot.SlotNavigation import com.arkivanov.decompose.router.slot.activate import com.arkivanov.decompose.router.slot.dismiss +import com.tangem.blockchain.common.Blockchain import com.tangem.blockchain.common.transaction.TransactionFee -import com.tangem.common.TangemBlogUrlBuilder +import com.tangem.blockchainsdk.utils.fromNetworkId import com.tangem.common.routing.AppRoute import com.tangem.common.routing.AppRouter import com.tangem.core.analytics.api.AnalyticsErrorHandler @@ -32,6 +33,7 @@ import com.tangem.core.ui.message.DialogMessage import com.tangem.core.ui.message.EventMessageAction import com.tangem.core.ui.utils.InputNumberFormatter import com.tangem.core.ui.utils.parseBigDecimal +import com.tangem.core.ui.utils.parseBigDecimalOrNull import com.tangem.datasource.local.appsflyer.AppsFlyerStore import com.tangem.domain.account.status.usecase.GetAccountCurrencyStatusUseCase import com.tangem.domain.account.status.usecase.GetFeePaidCryptoCurrencyStatusSyncUseCase @@ -54,11 +56,11 @@ import com.tangem.domain.models.network.Network import com.tangem.domain.models.wallet.UserWalletId import com.tangem.domain.pay.WithdrawalResult import com.tangem.domain.pay.usecase.GetPaymentAccountCryptoCurrencyStatusUseCase -import com.tangem.domain.stories.ShouldShowStoriesUseCase -import com.tangem.domain.stories.models.StoryContentIds import com.tangem.domain.settings.usercountry.GetUserCountryUseCase import com.tangem.domain.settings.usercountry.models.UserCountry import com.tangem.domain.settings.usercountry.models.needApplyFCARestrictions +import com.tangem.domain.stories.ShouldShowStoriesUseCase +import com.tangem.domain.stories.models.StoryContentIds import com.tangem.domain.swap.models.SwapCurrencyStatus import com.tangem.domain.tangempay.GetTangemPayCustomerIdUseCase import com.tangem.domain.tangempay.TangemPayWithdrawUseCase @@ -76,18 +78,16 @@ import com.tangem.feature.swap.domain.AllowPermissionsHandler import com.tangem.feature.swap.domain.GetSwapUiModeUseCase import com.tangem.feature.swap.domain.SetSwapUiModeUseCase import com.tangem.feature.swap.domain.SwapInteractor -import com.tangem.feature.swap.domain.TransactionFeeResult -import com.tangem.feature.swap.domain.TxFeeSealedState +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.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.domain.SwapUIMode +import com.tangem.feature.swap.domain.models.domain.* import com.tangem.feature.swap.domain.models.ui.* import com.tangem.feature.swap.domain.transfer.SwapTransferInteractor -import com.tangem.feature.swap.models.* +import com.tangem.feature.swap.models.SwapAlertUM +import com.tangem.feature.swap.models.SwapStateHolder +import com.tangem.feature.swap.models.TokenSelectionDirection +import com.tangem.feature.swap.models.UiActions import com.tangem.feature.swap.models.states.SwapNotificationUM import com.tangem.feature.swap.router.SwapRoute import com.tangem.feature.swap.ui.StateBuilder @@ -98,6 +98,7 @@ import com.tangem.features.approval.api.GiveApprovalComponent import com.tangem.features.commonfeatures.api.choosetoken.ChooseTokenAnalyticsPayload import com.tangem.features.commonfeatures.api.choosetoken.ChooseTokenBridge import com.tangem.features.commonfeatures.api.choosetoken.ChooseTokenResult +import com.tangem.features.send.v2.api.entity.FeeItem import com.tangem.features.send.v2.api.entity.FeeSelectorUM import com.tangem.features.send.v2.api.subcomponents.feeSelector.FeeSelectorReloadTrigger import com.tangem.features.swap.SwapComponent @@ -157,7 +158,7 @@ internal class SwapModel @Inject constructor( private val messageSender: UiMessageSender, private val initialCurrenciesResolver: InitialCurrenciesResolver, private val allowPermissionsHandler: AllowPermissionsHandler, - private val swapFeatureToggles: SwapFeatureToggles, + swapFeatureToggles: SwapFeatureToggles, private val getSwapUiModeUseCase: GetSwapUiModeUseCase, private val setSwapUiModeUseCase: SetSwapUiModeUseCase, ) : Model() { @@ -200,8 +201,7 @@ internal class SwapModel @Inject constructor( ) private val inputNumberFormatter = InputNumberFormatter( - NumberFormat.getInstance(Locale.getDefault()) as? DecimalFormat - ?: error("NumberFormat is not DecimalFormat"), + NumberFormat.getInstance(Locale.getDefault()) as? DecimalFormat ?: error("NumberFormat is not DecimalFormat"), ) private val amountDebouncer = Debouncer() @@ -285,8 +285,7 @@ internal class SwapModel @Inject constructor( } } - userCountry = getUserCountryUseCase.invokeSync().getOrNull() - ?: UserCountry.Other(Locale.getDefault().country) + userCountry = getUserCountryUseCase.invokeSync().getOrNull() ?: UserCountry.Other(Locale.getDefault().country) initTokens() @@ -333,33 +332,25 @@ internal class SwapModel @Inject constructor( } } - chooseFromTokenBridge.onCurrencyChosen.receiveAsFlow() - .onEach { result -> - onTokenSelect(result = result, isFromDirection = true) - sendAnalytics(result = result, direction = "From") - } - .launchIn(modelScope) + chooseFromTokenBridge.onCurrencyChosen.receiveAsFlow().onEach { result -> + onTokenSelect(result = result, isFromDirection = true) + sendAnalytics(result = result, direction = "From") + }.launchIn(modelScope) - chooseFromTokenBridge.onClose.receiveAsFlow() - .onEach { - analyticsEventHandler.send(SwapEvents.ChooseTokenScreenResult(isTokenChosen = false)) - router.pop() - } - .launchIn(modelScope) + chooseFromTokenBridge.onClose.receiveAsFlow().onEach { + analyticsEventHandler.send(SwapEvents.ChooseTokenScreenResult(isTokenChosen = false)) + router.pop() + }.launchIn(modelScope) - chooseToTokenBridge.onCurrencyChosen.receiveAsFlow() - .onEach { result -> - onTokenSelect(result, isFromDirection = false) - sendAnalytics(result = result, direction = "To") - } - .launchIn(modelScope) + chooseToTokenBridge.onCurrencyChosen.receiveAsFlow().onEach { result -> + onTokenSelect(result, isFromDirection = false) + sendAnalytics(result = result, direction = "To") + }.launchIn(modelScope) - chooseToTokenBridge.onClose.receiveAsFlow() - .onEach { - analyticsEventHandler.send(SwapEvents.ChooseTokenScreenResult(isTokenChosen = false)) - router.pop() - } - .launchIn(modelScope) + chooseToTokenBridge.onClose.receiveAsFlow().onEach { + analyticsEventHandler.send(SwapEvents.ChooseTokenScreenResult(isTokenChosen = false)) + router.pop() + }.launchIn(modelScope) } private fun initTokens() { @@ -568,17 +559,17 @@ internal class SwapModel @Inject constructor( if (newFromSwapCurrencyStatus != null && newToSwapCurrencyStatus != null) { updateFeePaidCryptoCurrencyFor(newFromSwapCurrencyStatus) - val toProvidersList = swapInteractor.findProvidersForPairWithCheck( - fromSwapCurrencyStatus = newFromSwapCurrencyStatus, - toSwapCurrencyStatus = newToSwapCurrencyStatus, - pairs = dataState.pairs, - ) val isUpdatedToTransferMode = isUpdatedToTransferMode( fromSwapCurrencyStatus = newFromSwapCurrencyStatus, toSwapCurrencyStatus = newToSwapCurrencyStatus, fromTokenAmount = lastAmount.value, ) if (isUpdatedToTransferMode) return@launch + val toProvidersList = swapInteractor.findProvidersForPairWithCheck( + fromSwapCurrencyStatus = newFromSwapCurrencyStatus, + toSwapCurrencyStatus = newToSwapCurrencyStatus, + pairs = dataState.pairs, + ) if (toProvidersList.isEmpty()) { handleSwapNotSupported( fromSwapCurrencyStatus = newFromSwapCurrencyStatus, @@ -698,6 +689,8 @@ internal class SwapModel @Inject constructor( ) if (shouldTransferInsteadOfSwap) { modelScope.launch { + singleTaskScheduler.destroyTask() + swapPairsJobHolder.cancel() updateTransferUIState(fromSwapCurrencyStatus, toSwapCurrencyStatus, fromTokenAmount) } } @@ -717,22 +710,31 @@ internal class SwapModel @Inject constructor( when (swapState) { is SwapState.EmptyAmountState -> setupEmptyAmountUiState(swapState, fromSwapCurrencyStatus) is SwapState.Transfer -> { + dataState = dataState.copy(amount = fromTokenAmount) uiState = swapTransferStateBuilder.createTransferState( actions = actions, transferState = swapState, uiStateHolder = uiState, ) + feeSelectorRepository.state.value = FeeSelectorUM.Loading + feeSelectorReloadTrigger.triggerUpdate() } is SwapState.QuotesLoadedState, is SwapState.SwapError -> Unit } } + private fun refreshTransferUIStateAfterFeeUpdate() { + val from = dataState.fromSwapCurrencyStatus ?: return + val to = dataState.toSwapCurrencyStatus ?: return + if (!swapTransferInteractor.shouldTransferInsteadOfSwap(from.currency, to.currency)) return + // todo notification check should be triggered (will be implemented in [REDACTED_TASK_KEY]) + } + private fun retrySwapPairs(fromSwapCurrencyStatus: SwapCurrencyStatus, toSwapCurrencyStatus: SwapCurrencyStatus) { if (swapPairsJobHolder.isActive) return initSwapPairs(fromSwapCurrencyStatus, toSwapCurrencyStatus) } - @Suppress("UnusedPrivateMember") private fun subscribeToCoinBalanceUpdatesIfNeeded() { val fromSwapCurrencyStatus = dataState.fromSwapCurrencyStatus val toSwapCurrencyStatus = dataState.toSwapCurrencyStatus @@ -833,6 +835,7 @@ internal class SwapModel @Inject constructor( dataState = dataState.copy(feePaidCryptoCurrency = feePaidCryptoCurrency) } + @Suppress("LongMethod") private fun loadQuotesTask( fromSwapCurrencyStatus: SwapCurrencyStatus, toSwapCurrencyStatus: SwapCurrencyStatus, @@ -846,11 +849,10 @@ internal class SwapModel @Inject constructor( delay = UPDATE_DELAY, task = { uiState = stateBuilder.createSilentLoadState(uiState) - runCatching(dispatchers.io) { + runCatching(dispatchers.default) { dataState = dataState.copy( amount = amount, reduceBalanceBy = reduceBalanceBy, - swapDataModel = null, ) swapInteractor.findBestQuote( fromSwapCurrencyStatus = fromSwapCurrencyStatus, @@ -858,39 +860,68 @@ internal class SwapModel @Inject constructor( providers = toProvidersList, amountToSwap = amount, reduceBalanceBy = reduceBalanceBy, - txFeeSealedState = getSelectedFeeState(), ) } }, onSuccess = { providersState -> - performanceTracker.onLoadingFinished( - hasError = providersState.values.none { it is SwapState.QuotesLoadedState }, - ) - if (providersState.isNotEmpty()) { - val (provider, state) = updateLoadedQuotes(providersState) - setupLoadedState( - provider = provider, - state = state, - fromSwapCurrencyStatus = fromSwapCurrencyStatus, - toSwapCurrencyStatus = toSwapCurrencyStatus, + modelScope.launch { + performanceTracker.onLoadingFinished( + hasError = providersState.values.none { it is SwapState.QuotesLoadedState }, ) - val successStates = providersState.getLastLoadedSuccessStates() - val pricesLowerBest = getPricesLowerBest(provider.providerId, successStates) - uiState = stateBuilder.updateProvidersBottomSheetContent( - uiState = uiState, - pricesLowerBest = pricesLowerBest, - tokenSwapInfoForProviders = successStates.entries - .associate { it.key.providerId to it.value.toTokenInfo }, - ) - if (shouldUpdateFeeBlock) { - modelScope.launch { feeSelectorReloadTrigger.triggerUpdate() } + + if (providersState.isNotEmpty()) { + val (provider, state) = updateLoadedQuotes(providersState) + + if (feeSelectorRepository.state.value is FeeSelectorUM.Content && + state is SwapState.QuotesLoadedState + ) { + val swapFee = getSelectedSwapFee() ?: return@launch + val patchedState = withContext(dispatchers.default) { + swapInteractor.applySwapFee( + state = state, + fee = swapFee, + lastReducedBalanceBy = lastReducedBalanceBy.value, + ) + } + val patchedStates = dataState.lastLoadedSwapStates.toMutableMap().apply { + put(provider, patchedState) + } + dataState = dataState.copy(lastLoadedSwapStates = patchedStates) + setupLoadedState( + provider = provider, + state = patchedState, + fromSwapCurrencyStatus = fromSwapCurrencyStatus, + toSwapCurrencyStatus = toSwapCurrencyStatus, + ) + } else { + setupLoadedState( + provider = provider, + state = state, + fromSwapCurrencyStatus = fromSwapCurrencyStatus, + toSwapCurrencyStatus = toSwapCurrencyStatus, + ) + } + + val successStates = providersState.getLastLoadedSuccessStates() + val pricesLowerBest = getPricesLowerBest(provider.providerId, successStates) + uiState = stateBuilder.updateProvidersBottomSheetContent( + uiState = uiState, + pricesLowerBest = pricesLowerBest, + tokenSwapInfoForProviders = successStates.entries + .associate { it.key.providerId to it.value.toTokenInfo }, + ) + val isPermissionNotNeeded = + dataState.getCurrentLoadedSwapState()?.permissionState == PermissionDataState.Empty + if (shouldUpdateFeeBlock && isPermissionNotNeeded) { + modelScope.launch { feeSelectorReloadTrigger.triggerUpdate() } + } else { + shouldUpdateFeeBlock = true + } } else { - shouldUpdateFeeBlock = true + feeSelectorRepository.state.value = + FeeSelectorUM.Error(GetFeeError.UnknownError, isHidden = true) + TangemLogger.e("Accidentally empty quotes list") } - } else { - feeSelectorRepository.state.value = - FeeSelectorUM.Error(GetFeeError.UnknownError, isHidden = true) - TangemLogger.e("Accidentally empty quotes list") } }, onError = { error -> @@ -927,7 +958,6 @@ internal class SwapModel @Inject constructor( } private fun setupQuotesLoadedUiState(provider: SwapProvider, state: SwapState.QuotesLoadedState) { - fillLoadedDataState(state.permissionState, state.swapDataModel) val loadedStates = dataState.lastLoadedSwapStates.getLastLoadedSuccessStates() val bestRatedProviderId = findBestQuoteProvider(loadedStates)?.providerId ?: provider.providerId uiState = stateBuilder.createQuotesLoadedState( @@ -937,9 +967,9 @@ internal class SwapModel @Inject constructor( swapProvider = provider, bestRatedProviderId = bestRatedProviderId, isNeedBestRateBadge = dataState.lastLoadedSwapStates.consideredProvidersStates().size > 1, - selectedFeeType = (getSelectedFee() as? TxFee.Legacy)?.feeType ?: FeeType.NORMAL, needApplyFCARestrictions = userCountry.needApplyFCARestrictions(), - hideFee = isTangemPayWithdrawal(), + swapFee = getSelectedSwapFee(), + feeError = feeSelectorRepository.state.value as? FeeSelectorUM.Error, ) } @@ -1017,8 +1047,9 @@ internal class SwapModel @Inject constructor( fromToken = state.fromTokenInfo, toSwapCurrencyStatus = dataState.toSwapCurrencyStatus, expressDataError = state.error, - includeFeeInAmount = state.includeFeeInAmount, + balanceStatus = state.balanceStatus, needApplyFCARestrictions = userCountry.needApplyFCARestrictions(), + swapFee = getSelectedSwapFee(), ) sendErrorAnalyticsEvent(state.error, provider) } @@ -1089,16 +1120,6 @@ internal class SwapModel @Inject constructor( } } - private fun fillLoadedDataState(permissionState: PermissionDataState, swapDataModel: SwapDataModel?) { - dataState = if (permissionState is PermissionDataState.PermissionRequired) { - dataState.copy() - } else { - dataState.copy( - swapDataModel = swapDataModel, - ) - } - } - @Suppress("LongMethod") private fun onSwapClick() { singleTaskScheduler.cancelTask() @@ -1111,10 +1132,10 @@ internal class SwapModel @Inject constructor( } val fromSwapCurrencyStatus = requireNotNull(dataState.fromSwapCurrencyStatus) val toSwapCurrencyStatus = requireNotNull(dataState.toSwapCurrencyStatus) - val fee = getSelectedFee() + val swapFee = getSelectedSwapFee() val isTangemPayWithdrawal = isTangemPayWithdrawal() - if (fee == null && !isTangemPayWithdrawal) { + if (swapFee == null && !isTangemPayWithdrawal) { TangemLogger.e("onSwapClick: fee is null and isWithdrawal is ${tangemPayInput?.isWithdrawal}") showAlert(resourceReference(R.string.swapping_fee_estimation_error_text)) modelScope.launch { @@ -1129,10 +1150,10 @@ internal class SwapModel @Inject constructor( fromSwapCurrencyStatus = fromSwapCurrencyStatus, toSwapCurrencyStatus = toSwapCurrencyStatus, swapProvider = provider, - swapData = dataState.swapDataModel, + swapData = lastLoadedQuotesState.swapDataModel, amountToSwap = requireNotNull(dataState.amount), - includeFeeInAmount = lastLoadedQuotesState.preparedSwapConfigState.includeFeeInAmount, - fee = fee, + balanceStatus = lastLoadedQuotesState.preparedSwapConfigState.balanceStatus, + fee = swapFee, expressOperationType = ExpressOperationType.SWAP, isTangemPayWithdrawal = isTangemPayWithdrawal, ) @@ -1140,14 +1161,15 @@ internal class SwapModel @Inject constructor( when (swapTransactionState) { is SwapTransactionState.TxSent -> { TangemLogger.i("onSwapClick: onSuccess: txHash: $swapTransactionState", shouldSanitize = false) - if (fee == null) { + if (swapFee == null) { TangemLogger.e("onSwapClick: onSuccess: fee is null after swap") showAlert(resourceReference(R.string.swapping_fee_estimation_error_text)) return@onSuccess } sendSuccessSwapEvent( - fromSwapCurrencyStatus.currency, - (getSelectedFee() as? TxFee.Legacy)?.feeType ?: FeeType.NORMAL, + fromToken = fromSwapCurrencyStatus.currency, + feeBucket = swapFee.feeBucket, + feeCryptoCurrency = dataState.feePaidCryptoCurrency, ) val url = getExplorerTransactionUrlUseCase( txHash = swapTransactionState.txHash, @@ -1163,6 +1185,7 @@ internal class SwapModel @Inject constructor( swapTransactionState = swapTransactionState, dataState = dataState, txUrl = url, + swapFee = swapFee, onExploreClick = { if (swapTransactionState.txHash.isNotEmpty()) { urlOpener.openUrl(url) @@ -1211,6 +1234,55 @@ internal class SwapModel @Inject constructor( } } + private fun onTransferClick() { + val fromSwapCurrencyStatus = dataState.fromSwapCurrencyStatus + val toSwapCurrencyStatus = dataState.toSwapCurrencyStatus + val fee = (feeSelectorRepository.state.value as? FeeSelectorUM.Content)?.selectedFeeItem?.fee + if (fromSwapCurrencyStatus == null || toSwapCurrencyStatus == null || fee == null) { + TangemLogger.e("onTransferClick: missing currency status or fee, aborting") + showAlert() + return + } + uiState = swapTransferStateBuilder.createTransferInProgressState(uiState) + modelScope.launch(dispatchers.main) { + swapTransferInteractor.sendTransfer( + fromSwapCurrencyStatus = fromSwapCurrencyStatus, + toSwapCurrencyStatus = toSwapCurrencyStatus, + fromTokenAmount = lastAmount.value, + fee = fee, + transactionFeeResult = requireNotNull(getSelectedSwapFee()?.transactionFeeResult) { + "It should be not null at this stage" + }, + ).fold( + ifLeft = { error -> + TangemLogger.e("onTransferClick: transfer failed: ${error.getAnalyticsDescription()}") + refreshTransferUIStateAfterFeeUpdate() + showAlert() + }, + ifRight = { txHash -> + val txUrl = getExplorerTransactionUrlUseCase( + txHash = txHash, + currency = fromSwapCurrencyStatus.currency, + ).getOrElse { + TangemLogger.i("onTransferClick: tx hash explore not supported") + "" + } + updateWalletBalance() + uiState = swapTransferStateBuilder.createSuccessState( + uiState = uiState, + dataState = dataState, + appCurrency = selectedAppCurrencyFlow.value, + isAccountsMode = isAccountsMode, + txUrl = txUrl, + timestamp = System.currentTimeMillis(), + fee = null, + ) + router.replaceAll(SwapRoute.Success) + }, + ) + } + } + private suspend fun processTangemPayWithdrawal( fromSwapCurrencyStatus: SwapCurrencyStatus, swapTransactionState: SwapTransactionState.TangemPayWithdrawalData, @@ -1221,45 +1293,43 @@ internal class SwapModel @Inject constructor( cryptoCurrencyId = swapTransactionState.cryptoCurrencyId, receiverCexAddress = swapTransactionState.cexAddress, exchangeData = swapTransactionState.exchangeData, - ) - .onLeft { - startLoadingQuotesFromLastState() - onTangemPayWithdrawalError(swapTransactionState.storeData.txExternalId) - } - .onRight { result: WithdrawalResult -> - when (result) { - WithdrawalResult.Cancelled -> { - startLoadingQuotesFromLastState() - } - WithdrawalResult.Success -> { - val txUrl = swapTransactionState.storeData.txExternalUrl - swapInteractor.storeSwapTransaction( - fromSwapCurrencyStatus = swapTransactionState.storeData.fromSwapCurrencyStatus, - toSwapCurrencyStatus = swapTransactionState.storeData.toSwapCurrencyStatus, - amount = swapTransactionState.storeData.amount, - swapProvider = swapTransactionState.storeData.swapProvider, - swapDataModel = swapTransactionState.storeData.swapDataModel, - txExternalUrl = txUrl, - timestamp = System.currentTimeMillis(), - txExternalId = swapTransactionState.storeData.txExternalId, - averageDuration = null, - ) - uiState = stateBuilder.createTangemPayWithdrawalSuccessState( - uiState = uiState, - swapTransactionState = swapTransactionState, - dataState = dataState, - txUrl = txUrl.orEmpty(), - onExploreClick = { if (txUrl != null) urlOpener.openUrl(txUrl) }, - ) - router.replaceAll(SwapRoute.Success) - } + ).onLeft { + startLoadingQuotesFromLastState() + onTangemPayWithdrawalError(swapTransactionState.storeData.txExternalId) + }.onRight { result: WithdrawalResult -> + when (result) { + WithdrawalResult.Cancelled -> { + startLoadingQuotesFromLastState() + } + WithdrawalResult.Success -> { + val txUrl = swapTransactionState.storeData.txExternalUrl + swapInteractor.storeSwapTransaction( + fromSwapCurrencyStatus = swapTransactionState.storeData.fromSwapCurrencyStatus, + toSwapCurrencyStatus = swapTransactionState.storeData.toSwapCurrencyStatus, + amount = swapTransactionState.storeData.amount, + swapProvider = swapTransactionState.storeData.swapProvider, + swapDataModel = swapTransactionState.storeData.swapDataModel, + txExternalUrl = txUrl, + timestamp = System.currentTimeMillis(), + txExternalId = swapTransactionState.storeData.txExternalId, + averageDuration = null, + ) + uiState = stateBuilder.createTangemPayWithdrawalSuccessState( + uiState = uiState, + swapTransactionState = swapTransactionState, + dataState = dataState, + txUrl = txUrl.orEmpty(), + onExploreClick = { if (txUrl != null) urlOpener.openUrl(txUrl) }, + ) + router.replaceAll(SwapRoute.Success) } } + } } private suspend fun sendSwapInProgressEvent() { val provider = dataState.selectedProvider ?: return - val fee = (getSelectedFee() as? TxFee.Legacy)?.feeType ?: FeeType.NORMAL + val feeBucket = getSelectedSwapFee()?.feeBucket ?: FeeBucket.MARKET val fromSwapCurrencyStatus = dataState.fromSwapCurrencyStatus ?: return val toSwapCurrencyStatus = dataState.toSwapCurrencyStatus ?: return val fromDerivationIndex = fromSwapCurrencyStatus.account.derivationIndex?.value @@ -1274,7 +1344,7 @@ internal class SwapModel @Inject constructor( analyticsEventHandler.send( SwapEvents.SwapInProgressScreen( provider = provider, - commission = fee, + commission = feeBucket, sendBlockchain = fromSwapCurrencyStatus.currency.network.name, receiveBlockchain = toSwapCurrencyStatus.currency.network.name, sendToken = fromSwapCurrencyStatus.currency.symbol, @@ -1333,9 +1403,7 @@ internal class SwapModel @Inject constructor( ), ) startLoadingQuotesFromLastState(isSilent = true) - } - .flowOn(dispatchers.main) - .launchIn(modelScope) + }.flowOn(dispatchers.main).launchIn(modelScope) .saveIn(if (isFromCurrency) fromTokenBalanceJobHolder else toTokenBalanceJobHolder) } @@ -1525,7 +1593,7 @@ internal class SwapModel @Inject constructor( } }, onTransferClick = { - // TODO: Will be implemented in [REDACTED_TASK_KEY] + onTransferClick() }, onChangeCardsClicked = { onChangeCardsClicked() @@ -1548,29 +1616,6 @@ internal class SwapModel @Inject constructor( approvalSlotNavigation.activate(Unit) }, onAmountSelected = { onAmountSelected(it) }, - onClickFee = { - val selectedFee = (getSelectedFee() as? TxFee.Legacy)?.feeType ?: FeeType.NORMAL - val txFeeState = - dataState.getCurrentLoadedSwapState()?.txFee as? TxFeeState.MultipleFeeState ?: return@UiActions - modelScope.launch { - val readMoreUrl = TangemBlogUrlBuilder.build(TangemBlogUrlBuilder.Post.WhatIsTransactionFee) - uiState = stateBuilder.showSelectFeeBottomSheet( - uiState = uiState, - selectedFee = selectedFee, - txFeeState = txFeeState, - readMoreUrl = readMoreUrl, - ) { - uiState = stateBuilder.dismissBottomSheet(uiState) - } - } - }, - onSelectFeeType = { txFee -> - uiState = stateBuilder.dismissBottomSheet(uiState) - dataState = dataState.copy(selectedFee = txFee) - modelScope.launch(dispatchers.io) { - startLoadingQuotesFromLastState(false) - } - }, onProviderClick = { providerId -> analyticsEventHandler.send(SwapEvents.ProviderClicked()) val states = dataState.lastLoadedSwapStates.getLastLoadedSuccessStates() @@ -1607,6 +1652,15 @@ internal class SwapModel @Inject constructor( onProviderFilterSelect = { filterType -> uiState = stateBuilder.updateProviderFilterType(uiState, filterType) }, + openTokenDetailsScreen = { cryptoCurrency -> + val fromSwapCurrencyStatus = dataState.fromSwapCurrencyStatus ?: return@UiActions + val route = AppRoute.CurrencyDetails( + userWalletId = fromSwapCurrencyStatus.userWalletId, + currency = cryptoCurrency, + ) + + appRouter.push(route) + }, onRetryClick = { startLoadingQuotesFromLastState() }, @@ -1659,15 +1713,11 @@ internal class SwapModel @Inject constructor( } else { val fromSwapCurrencyStatus = dataState.fromSwapCurrencyStatus val toSwapCurrencyStatus = dataState.toSwapCurrencyStatus - val shouldShowSameCoinsWithDifferentAddress = swapFeatureToggles.isSwapSwitchToTransferEnabled && - fromSwapCurrencyStatus?.account?.accountId != accountStatus.accountId && - fromSwapCurrencyStatus?.currency?.network?.rawId == toSwapCurrencyStatus?.currency?.network?.rawId (fromSwapCurrencyStatus?.account?.accountId != accountStatus.accountId || fromSwapCurrencyStatus.currency.id != currencyStatus.currency.id) && (toSwapCurrencyStatus?.account?.accountId != accountStatus.accountId || - toSwapCurrencyStatus.currency.id != currencyStatus.currency.id) || - shouldShowSameCoinsWithDifferentAddress + toSwapCurrencyStatus.currency.id != currencyStatus.currency.id) } } @@ -1675,9 +1725,12 @@ internal class SwapModel @Inject constructor( chooseToTokenBridge.tokenFilter.value = tokenFilter } - private fun sendSuccessSwapEvent(fromToken: CryptoCurrency, feeType: FeeType) { - val feeToken = getFeeToken() - val feeAssetType = if (feeToken is CryptoCurrency.Coin) { + private fun sendSuccessSwapEvent( + fromToken: CryptoCurrency, + feeBucket: FeeBucket, + feeCryptoCurrency: CryptoCurrencyStatus?, + ) { + val feeAssetType = if (feeCryptoCurrency?.currency is CryptoCurrency.Coin) { AnalyticsParam.FeeAssetType.Coin } else { AnalyticsParam.FeeAssetType.Token @@ -1685,8 +1738,8 @@ internal class SwapModel @Inject constructor( val event = AnalyticsParam.TxSentFrom.Swap( blockchain = fromToken.network.name, token = fromToken.symbol, - feeType = AnalyticsParam.FeeType.fromString(feeType.getNameForAnalytics()), - feeToken = feeToken.symbol, + feeType = AnalyticsParam.FeeType.fromString(feeBucket.toAnalyticsName()), + feeToken = getFeeToken().symbol, feeAssetType = feeAssetType, ) analyticsEventHandler.send( @@ -1701,12 +1754,7 @@ internal class SwapModel @Inject constructor( val fromToken = requireNotNull(dataState.fromSwapCurrencyStatus) { "fromCryptoCurrency should not be null" } - return when (val fee = getSelectedFee()) { - is TxFee.FeeComponent -> fee.selectedToken?.currency ?: fromToken.currency - is TxFee.Legacy, - null, - -> fromToken.currency - } + return getSelectedSwapFee()?.selectedFeeToken?.currency ?: fromToken.currency } private fun findAndSelectProvider(providerId: String): SwapProvider? { @@ -1738,10 +1786,9 @@ internal class SwapModel @Inject constructor( } private fun getPricesLowerBest(selectedProviderId: String, state: SuccessLoadedSwapData): Map { - val selectedProviderEntry = state - .filter { entry -> entry.key.providerId == selectedProviderId } - .entries - .firstOrNull() ?: return emptyMap() + val selectedProviderEntry = + state.filter { entry -> entry.key.providerId == selectedProviderId }.entries.firstOrNull() + ?: return emptyMap() val selectedProviderRate = selectedProviderEntry.value.toTokenInfo.tokenAmount.value val hundredPercent = BigDecimal("100") return state.entries.mapNotNull { entry -> @@ -1880,11 +1927,12 @@ internal class SwapModel @Inject constructor( private fun onFailedTxEmailClick(errorMessage: String) { modelScope.launch { - val transaction = dataState.swapDataModel?.transaction + val transaction = dataState.getCurrentLoadedSwapState()?.swapDataModel?.transaction val fromSwapCurrencyStatus = dataState.fromSwapCurrencyStatus val fromCurrency = fromSwapCurrencyStatus?.currency ?: params.cryptoCurrency val fromWalletId = fromSwapCurrencyStatus?.userWalletId ?: params.userWalletId val network = fromCurrency?.network + val fee = getSelectedSwapFee()?.fee saveBlockchainErrorUseCase( error = BlockchainErrorInfo( @@ -1893,16 +1941,11 @@ internal class SwapModel @Inject constructor( destinationAddress = transaction?.txTo.orEmpty(), tokenSymbol = fromCurrency?.symbol.orEmpty(), amount = dataState.amount.orEmpty(), - fee = when (val fee = getSelectedFee()) { - is TxFee.FeeComponent -> fee.fee.amount.value?.toString() - is TxFee.Legacy -> fee.feeCryptoFormatted - null -> "" - }, + fee = fee?.amount?.value?.toString().orEmpty(), ), ) - val metaInfo = getWalletMetaInfoUseCase(fromWalletId) - .getOrElse { error("CardInfo must be not null") } + val metaInfo = getWalletMetaInfoUseCase(fromWalletId).getOrElse { error("CardInfo must be not null") } val email = FeedbackEmailType.SwapProblem( walletMetaInfo = metaInfo, @@ -1915,54 +1958,65 @@ internal class SwapModel @Inject constructor( } } - private fun getSelectedFeeState(): TxFeeSealedState { + /** + * Builds a [SwapFee] from the current fee selector state. Returns null + * when the selector isn't in a `Content` state (e.g. still loading, error). Mirrors the + * mapping rules from the redesign plan: + * - `transactionFeeResult` comes from `transactionFeeExtended` (gasless) or `fees` (native). + * - `fee` is the user-selected `FeeItem.fee` (authoritative). + * - `feeBucket` is mapped from the `FeeItem` variant. + * - `selectedFeeToken` is the fee currency from `feeExtraInfo`. + * - `otherNativeFee` is sourced from `dataState.swapDataModel.transaction` (DEX bridge only). + */ + private fun getSelectedSwapFee(): SwapFee? { val feeStateUM = feeSelectorRepository.state.value as? FeeSelectorUM.Content - if (feeStateUM == null) { TangemLogger.e( - messageString = "getSelectedFeeState: FeeSelectorUM is not Content: $feeStateUM, " + - "returning Legacy state", - shouldSanitize = false, - ) - return TxFeeSealedState.Legacy( - txFeeState = TxFeeState.Empty, - selectedFee = dataState.selectedFee?.feeType ?: FeeType.NORMAL, - ) - } - - val transactionFeeExtended = feeStateUM.feeExtraInfo.transactionFeeExtended - return TxFeeSealedState.Component( - txFee = TxFee.FeeComponent( - transactionFeeResult = transactionFeeExtended?.let { TransactionFeeResult.from(it) } - ?: TransactionFeeResult.from(feeStateUM.fees), - fee = feeStateUM.selectedFeeItem.fee, - selectedToken = feeStateUM.feeExtraInfo.feeCryptoCurrencyStatus, - ), - ) - } - - private fun getSelectedFee(): TxFee? { - val feeStateUM = feeSelectorRepository.state.value as? FeeSelectorUM.Content - - if (feeStateUM == null) { - TangemLogger.e( - messageString = "getSelectedFee: FeeSelectorUM is not Content: $feeStateUM, returning null", + messageString = "getSelectedSwapFee: FeeSelectorUM is not Content: $feeStateUM, returning null", shouldSanitize = false, ) return null } - val transactionFeeExtended = feeStateUM.feeExtraInfo.transactionFeeExtended - - return TxFee.FeeComponent( - transactionFeeResult = transactionFeeExtended?.let { TransactionFeeResult.from(it) } - ?: TransactionFeeResult.from(feeStateUM.fees), + val transactionFeeResult = + transactionFeeExtended?.let { TransactionFeeResult.from(it) } ?: TransactionFeeResult.from(feeStateUM.fees) + return SwapFee( fee = feeStateUM.selectedFeeItem.fee, - selectedToken = feeStateUM.feeExtraInfo.feeCryptoCurrencyStatus, + transactionFeeResult = transactionFeeResult, + selectedFeeToken = feeStateUM.feeExtraInfo.feeCryptoCurrencyStatus, + otherNativeFee = resolveOtherNativeFee(), + feeBucket = feeStateUM.selectedFeeItem.toFeeBucket(), ) } - @Suppress("UnsafeCallOnNullableType") + /** + * [REDACTED_TASK_KEY] — Phase 4. Extracts the bridge protocol fee from the cached + * [SwapDataModel.transaction] payload (DEX bridge providers carry `otherNativeFeeWei`). + * + * The UI's `FeeSelectorUM` doesn't carry this value, so we read it from the most-recent + * swap data. Returns [BigDecimal.ZERO] when no swap data is cached, the transaction is not + * a DEX payload, or `otherNativeFeeWei` is null (non-bridge providers). + */ + private fun resolveOtherNativeFee(): BigDecimal { + val transaction = + dataState.getCurrentLoadedSwapState()?.swapDataModel?.transaction as? ExpressTransactionModel.DEX + ?: return BigDecimal.ZERO + val otherNativeFeeWei = transaction.otherNativeFeeWei ?: return BigDecimal.ZERO + val nativeDecimals = dataState.fromSwapCurrencyStatus?.currency?.network?.let { network -> + Blockchain.fromNetworkId(network.rawId)?.decimals() + } ?: return BigDecimal.ZERO + return otherNativeFeeWei.movePointLeft(nativeDecimals) + } + + private fun FeeItem.toFeeBucket(): FeeBucket = when (this) { + is FeeItem.Slow -> FeeBucket.SLOW + is FeeItem.Market -> FeeBucket.MARKET + is FeeItem.Fast -> FeeBucket.FAST + is FeeItem.Suggested -> FeeBucket.SUGGESTED + is FeeItem.Custom -> FeeBucket.CUSTOM + is FeeItem.Loading -> FeeBucket.MARKET + } + inner class FeeSelectorRepository : SwapFeeSelectorBlockComponent.ModelRepositoryExtended { override val state = MutableStateFlow( @@ -1971,112 +2025,170 @@ internal class SwapModel @Inject constructor( override val forceUpdateState = MutableSharedFlow() - override suspend fun loadFeeExtended( - selectedToken: CryptoCurrencyStatus?, - ): Either { - // TODO use getFeeGaselessUsecase in transfer. Will be implemented in [REDACTED_TASK_KEY] - val fromSwapCurrencyStatus = - dataState.fromSwapCurrencyStatus ?: return Either.Left(GetFeeError.UnknownError) - val selectedProvider = dataStateStateFlow.first { it.selectedProvider != null }.selectedProvider!! - - if (selectedProvider.type != ExchangeProviderType.CEX) { - return Either.Left(GetFeeError.GaslessError.NetworkIsNotSupported) - } - - if (dataState.lastLoadedSwapStates[selectedProvider] !is SwapState.QuotesLoadedState) { - return Either.Left(GetFeeError.UnknownError) - } - - if (isPermissionNotificationShown()) { - return Either.Left(GetFeeError.UnknownError) - } - - return swapInteractor.loadFeeForSwapTransaction( - fromSwapCurrencyStatus = fromSwapCurrencyStatus, - provider = selectedProvider, - amount = lastAmount.value, - reduceBalanceBy = lastReducedBalanceBy.value, - selectedFeeToken = selectedToken, - ) - } - - override fun onResult(newState: FeeSelectorUM) { - state.value = newState - - if (newState is FeeSelectorUM.Error) { - modelScope.launch { - TangemLogger.e("onResult: FeeSelectorUM is Error, isHidden = true") - forceUpdateState.emit(newState.copy(isHidden = true)) - } - return - } - - val fromSwapCurrencyStatus = dataState.fromSwapCurrencyStatus - - // If fee currency is same as from currency, we need to reload quotes to update fee info - val isFeeCurrencySameAsFromCurrency = newState is FeeSelectorUM.Content && - fromSwapCurrencyStatus?.currency?.id == newState.feeExtraInfo.feeCryptoCurrencyStatus.currency.id - - // If fee currency is coin, we need to reload quotes to update fee related warnings (e.g. insufficient funds) - val isCoinFeeSelected = newState is FeeSelectorUM.Content && - newState.feeExtraInfo.feeCryptoCurrencyStatus.currency is CryptoCurrency.Coin - - if (isFeeCurrencySameAsFromCurrency || isCoinFeeSelected) { - TangemLogger.e("onResult: Fee currency is same as from currency or coin fee selected, reloading quotes") - - // block swap button until fee is loaded - uiState = uiState.copy( - swapButton = uiState.swapButton.copy( - isEnabled = false, - mode = SwapButton.Mode.SWAP_PROGRESSING, - ), - ) - modelScope.launch { - startLoadingQuotesFromLastState( - isSilent = true, - updateFeeBlock = false, - ) - } - } - } - - private fun isPermissionNotificationShown(): Boolean { - val permissionState = dataState.getCurrentLoadedSwapState()?.permissionState - return permissionState != null && permissionState !is PermissionDataState.Empty - } - override suspend fun loadFee(): Either { - TangemLogger.e("loadFee: Start loading fee") - // TODO use getFeeUsecase in transfer. Will be implemented in [REDACTED_TASK_KEY] val fromSwapCurrencyStatus = dataState.fromSwapCurrencyStatus ?: return Either.Left(GetFeeError.UnknownError) val toSwapCurrencyStatus = dataState.toSwapCurrencyStatus ?: return Either.Left(GetFeeError.UnknownError) - val selectedProvider = dataStateStateFlow.first { it.selectedProvider != null }.selectedProvider!! - - if (dataState.lastLoadedSwapStates[selectedProvider] !is SwapState.QuotesLoadedState) { - TangemLogger.e( - messageString = "loadFee: Quotes not loaded ${dataState.lastLoadedSwapStates[selectedProvider]}", - shouldSanitize = false, - ) - return Either.Left(GetFeeError.UnknownError) + val shouldTransferInsteadOfSwap = swapTransferInteractor.shouldTransferInsteadOfSwap( + fromSwapCurrencyStatus.currency, + toSwapCurrencyStatus.currency, + ) + if (shouldTransferInsteadOfSwap) { + return swapTransferInteractor.loadFee( + fromSwapCurrencyStatus = fromSwapCurrencyStatus, + toSwapCurrencyStatus = toSwapCurrencyStatus, + fromTokenAmount = lastAmount.value, + ).onLeft { + TangemLogger.e("loadFee[transfer]: Failed to load fee with error $it") + }.onRight { + TangemLogger.e("loadFee[transfer]: Fee loaded successfully") + } } + val quoteState = dataState.getCurrentLoadedSwapState() ?: return Either.Left(GetFeeError.UnknownError) if (isPermissionNotificationShown()) { TangemLogger.e("loadFee: Permission notification is shown, cannot load fee") return Either.Left(GetFeeError.UnknownError) } - return swapInteractor.loadFeeForSwapTransaction( - fromSwapCurrencyStatus = fromSwapCurrencyStatus, - toSwapCurrencyStatus = toSwapCurrencyStatus, - provider = selectedProvider, - amount = lastAmount.value, - reduceBalanceBy = lastReducedBalanceBy.value, - ).onLeft { + val amountDecimal = lastAmount.value.replace(",", ".").toBigDecimalOrNull() + ?: return Either.Left(GetFeeError.UnknownError) + val swapAmount = SwapAmount(amountDecimal, fromSwapCurrencyStatus.currency.decimals) + val swapDataForCall = when (quoteState.swapProvider.type) { + ExchangeProviderType.DEX, ExchangeProviderType.DEX_BRIDGE -> { + quoteState.swapDataModel ?: return Either.Left(GetFeeError.UnknownError) + } + ExchangeProviderType.CEX -> null + } + return swapInteractor.loadSwapFee( + provider = quoteState.swapProvider, + fromStatus = fromSwapCurrencyStatus, + toStatus = toSwapCurrencyStatus, + amount = swapAmount, + swapData = swapDataForCall, + selectedFeeToken = null, + ).map { swapFee -> + when (val res = swapFee.transactionFeeResult) { + is TransactionFeeResult.LoadedExtended -> res.fee.transactionFee + is TransactionFeeResult.Loaded -> res.fee + } + }.onLeft { TangemLogger.e("loadFee: Failed to load fee with error $it") - }.onRight { - TangemLogger.e("loadFee: Fee loaded successfully") + } + } + + override suspend fun loadFeeExtended( + selectedToken: CryptoCurrencyStatus?, + ): Either { + val fromSwapCurrencyStatus = + dataState.fromSwapCurrencyStatus ?: return Either.Left(GetFeeError.UnknownError) + val toSwapCurrencyStatus = + dataState.toSwapCurrencyStatus ?: return Either.Left(GetFeeError.UnknownError) + val shouldTransferInsteadOfSwap = swapTransferInteractor.shouldTransferInsteadOfSwap( + fromSwapCurrencyStatus.currency, + toSwapCurrencyStatus.currency, + ) + if (shouldTransferInsteadOfSwap) { + return swapTransferInteractor.loadFeeExtended( + fromSwapCurrencyStatus = fromSwapCurrencyStatus, + toSwapCurrencyStatus = toSwapCurrencyStatus, + fromTokenAmount = lastAmount.value, + ) + } + val quoteState = dataState.getCurrentLoadedSwapState() ?: return Either.Left(GetFeeError.UnknownError) + + if (isPermissionNotificationShown()) { + return Either.Left(GetFeeError.UnknownError) + } + + val amountDecimal = lastAmount.value.parseBigDecimalOrNull() ?: return Either.Left(GetFeeError.UnknownError) + val swapAmount = SwapAmount(amountDecimal, fromSwapCurrencyStatus.currency.decimals) + + // DEX path requires a SwapDataModel. + val swapDataForCall = when (quoteState.swapProvider.type) { + ExchangeProviderType.DEX, ExchangeProviderType.DEX_BRIDGE -> { + // TODO support gasless in DEX/DEX_BRIDGE + return Either.Left(GetFeeError.GaslessError.NetworkIsNotSupported) + } + ExchangeProviderType.CEX -> null + } + + return swapInteractor.loadSwapFee( + provider = quoteState.swapProvider, + fromStatus = fromSwapCurrencyStatus, + toStatus = toSwapCurrencyStatus, + amount = swapAmount, + swapData = swapDataForCall, + selectedFeeToken = selectedToken, + ).map { swapFee -> + // The fee selector block consumes TransactionFeeExtended; build one when + // `transactionFeeResult` is LoadedExtended, else wrap the native fee in a + // pass-through TransactionFeeExtended for compatibility with the block API. + when (val res = swapFee.transactionFeeResult) { + is TransactionFeeResult.LoadedExtended -> res.fee + is TransactionFeeResult.Loaded -> TransactionFeeExtended( + transactionFee = res.fee, + feeTokenId = swapFee.selectedFeeToken.currency.id, + ) + } + } + } + + override fun onResult(newState: FeeSelectorUM) { + state.value = newState + + val quoteState = dataState.getCurrentLoadedSwapState() ?: return + + if (newState is FeeSelectorUM.Error) { + TangemLogger.e("loadFee: ${newState.error}, isHidden = true") + uiState = stateBuilder.createFeeErrorState( + uiStateHolder = uiState, + quoteModel = quoteState, + feeCryptoCurrencyStatus = dataState.feePaidCryptoCurrency, + feeError = newState.error, + ) + modelScope.launch { forceUpdateState.emit(newState.copy(isHidden = true)) } + refreshTransferUIStateAfterFeeUpdate() + return + } + refreshTransferUIStateAfterFeeUpdate() + + val fromSwapCurrencyStatus = dataState.fromSwapCurrencyStatus + val toSwapCurrencyStatus = dataState.toSwapCurrencyStatus + // Transfer mode has its own fee pipeline and doesn't use swap quotes. + val shouldTransferInsteadOfSwap = swapTransferInteractor.shouldTransferInsteadOfSwap( + fromSwapCurrencyStatus?.currency, + toSwapCurrencyStatus?.currency, + ) + if (shouldTransferInsteadOfSwap) return + + val swapFee = getSelectedSwapFee() ?: return + + modelScope.launch(dispatchers.default) { + val patchedState = swapInteractor.applySwapFee( + state = quoteState, + fee = swapFee, + lastReducedBalanceBy = lastReducedBalanceBy.value, + ) + val patchedStates = dataState.lastLoadedSwapStates.toMutableMap().apply { + put(quoteState.swapProvider, patchedState) + } + withContext(dispatchers.main) { + dataState = dataState.copy( + lastLoadedSwapStates = patchedStates, + feePaidCryptoCurrency = swapFee.selectedFeeToken, + ) + // Refresh UI via the existing pipeline. + val updatedFromSwapCurrencyStatus = dataState.fromSwapCurrencyStatus ?: return@withContext + val updatedToSwapCurrencyStatus = dataState.toSwapCurrencyStatus ?: return@withContext + setupLoadedState( + provider = quoteState.swapProvider, + state = patchedState, + fromSwapCurrencyStatus = updatedFromSwapCurrencyStatus, + toSwapCurrencyStatus = updatedToSwapCurrencyStatus, + ) + } } } @@ -2085,12 +2197,14 @@ internal class SwapModel @Inject constructor( if (updatedState) { singleTaskScheduler.cancelTask() } else { - startLoadingQuotesFromLastState( - isSilent = true, - updateFeeBlock = false, - ) + singleTaskScheduler.resumeLastTask(modelScope) } } + + private fun isPermissionNotificationShown(): Boolean { + val permissionState = dataState.getCurrentLoadedSwapState()?.permissionState + return permissionState != null && permissionState !is PermissionDataState.Empty + } } private companion object { diff --git a/features/swap/impl/src/main/java/com/tangem/feature/swap/model/SwapNotificationsFactory.kt b/features/swap/impl/src/main/java/com/tangem/feature/swap/model/SwapNotificationsFactory.kt index e3e66f9f71..91e28c1b4e 100644 --- a/features/swap/impl/src/main/java/com/tangem/feature/swap/model/SwapNotificationsFactory.kt +++ b/features/swap/impl/src/main/java/com/tangem/feature/swap/model/SwapNotificationsFactory.kt @@ -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 = 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 { 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 { 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.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.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.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.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.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() } \ No newline at end of file diff --git a/features/swap/impl/src/main/java/com/tangem/feature/swap/model/SwapProcessDataState.kt b/features/swap/impl/src/main/java/com/tangem/feature/swap/model/SwapProcessDataState.kt index 0f2e373166..84f8f76b2d 100644 --- a/features/swap/impl/src/main/java/com/tangem/feature/swap/model/SwapProcessDataState.kt +++ b/features/swap/impl/src/main/java/com/tangem/feature/swap/model/SwapProcessDataState.kt @@ -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? { diff --git a/features/swap/impl/src/main/java/com/tangem/feature/swap/models/CurrenciesGroupWithFromCurrency.kt b/features/swap/impl/src/main/java/com/tangem/feature/swap/models/CurrenciesGroupWithFromCurrency.kt deleted file mode 100644 index c7a807127a..0000000000 --- a/features/swap/impl/src/main/java/com/tangem/feature/swap/models/CurrenciesGroupWithFromCurrency.kt +++ /dev/null @@ -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, -) \ No newline at end of file diff --git a/features/swap/impl/src/main/java/com/tangem/feature/swap/models/SwapStateHolder.kt b/features/swap/impl/src/main/java/com/tangem/feature/swap/models/SwapStateHolder.kt index aba41ecb33..df7055d840 100644 --- a/features/swap/impl/src/main/java/com/tangem/feature/swap/models/SwapStateHolder.kt +++ b/features/swap/impl/src/main/java/com/tangem/feature/swap/models/SwapStateHolder.kt @@ -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, diff --git a/features/swap/impl/src/main/java/com/tangem/feature/swap/models/SwapSuccessStateHolder.kt b/features/swap/impl/src/main/java/com/tangem/feature/swap/models/SwapSuccessStateHolder.kt index 1d50867cc6..63670c2dff 100644 --- a/features/swap/impl/src/main/java/com/tangem/feature/swap/models/SwapSuccessStateHolder.kt +++ b/features/swap/impl/src/main/java/com/tangem/feature/swap/models/SwapSuccessStateHolder.kt @@ -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, -) \ No newline at end of file +) { + val shouldShowProvider: Boolean + get() = !isTransferMode +} \ No newline at end of file diff --git a/features/swap/impl/src/main/java/com/tangem/feature/swap/models/UiActions.kt b/features/swap/impl/src/main/java/com/tangem/feature/swap/models/UiActions.kt index f49d855dea..7638bbbae6 100644 --- a/features/swap/impl/src/main/java/com/tangem/feature/swap/models/UiActions.kt +++ b/features/swap/impl/src/main/java/com/tangem/feature/swap/models/UiActions.kt @@ -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, diff --git a/features/swap/impl/src/main/java/com/tangem/feature/swap/models/states/ChooseFeeBottomSheetConfig.kt b/features/swap/impl/src/main/java/com/tangem/feature/swap/models/states/ChooseFeeBottomSheetConfig.kt deleted file mode 100644 index f54845cd51..0000000000 --- a/features/swap/impl/src/main/java/com/tangem/feature/swap/models/states/ChooseFeeBottomSheetConfig.kt +++ /dev/null @@ -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, - val readMoreUrl: String, - val readMore: TextReference, - val onReadMoreClick: (String) -> Unit, -) : TangemBottomSheetConfigContent \ No newline at end of file diff --git a/features/swap/impl/src/main/java/com/tangem/feature/swap/models/states/FeeItemState.kt b/features/swap/impl/src/main/java/com/tangem/feature/swap/models/states/FeeItemState.kt deleted file mode 100644 index 8c3218aa96..0000000000 --- a/features/swap/impl/src/main/java/com/tangem/feature/swap/models/states/FeeItemState.kt +++ /dev/null @@ -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() -} \ No newline at end of file diff --git a/features/swap/impl/src/main/java/com/tangem/feature/swap/preview/FeeItemStatePreview.kt b/features/swap/impl/src/main/java/com/tangem/feature/swap/preview/FeeItemStatePreview.kt deleted file mode 100644 index 4d066c82d2..0000000000 --- a/features/swap/impl/src/main/java/com/tangem/feature/swap/preview/FeeItemStatePreview.kt +++ /dev/null @@ -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) -} \ No newline at end of file diff --git a/features/swap/impl/src/main/java/com/tangem/feature/swap/preview/SwapSuccessStatePreview.kt b/features/swap/impl/src/main/java/com/tangem/feature/swap/preview/SwapSuccessStatePreview.kt index df255cc346..b9188943e9 100644 --- a/features/swap/impl/src/main/java/com/tangem/feature/swap/preview/SwapSuccessStatePreview.kt +++ b/features/swap/impl/src/main/java/com/tangem/feature/swap/preview/SwapSuccessStatePreview.kt @@ -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"), diff --git a/features/swap/impl/src/main/java/com/tangem/feature/swap/ui/ChooseFeeBottomSheet.kt b/features/swap/impl/src/main/java/com/tangem/feature/swap/ui/ChooseFeeBottomSheet.kt deleted file mode 100644 index fdab5fd345..0000000000 --- a/features/swap/impl/src/main/java/com/tangem/feature/swap/ui/ChooseFeeBottomSheet.kt +++ /dev/null @@ -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 \ No newline at end of file diff --git a/features/swap/impl/src/main/java/com/tangem/feature/swap/ui/FeeItem.kt b/features/swap/impl/src/main/java/com/tangem/feature/swap/ui/FeeItem.kt deleted file mode 100644 index b3cb10ce22..0000000000 --- a/features/swap/impl/src/main/java/com/tangem/feature/swap/ui/FeeItem.kt +++ /dev/null @@ -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 { - override val values: Sequence - get() = sequenceOf( - FeeItemStatePreview.state, - FeeItemStatePreview.state.copy(isClickable = true), - ) -} -// endregion \ No newline at end of file diff --git a/features/swap/impl/src/main/java/com/tangem/feature/swap/ui/StateBuilder.kt b/features/swap/impl/src/main/java/com/tangem/feature/swap/ui/StateBuilder.kt index 43a6714263..ce723c9100 100644 --- a/features/swap/impl/src/main/java/com/tangem/feature/swap/ui/StateBuilder.kt +++ b/features/swap/impl/src/main/java/com/tangem/feature/swap/ui/StateBuilder.kt @@ -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, priceImpact: PriceImpact): Boolean { - return notifications.none { notification -> + private fun getSwapButtonEnabled( + notifications: ImmutableList, + 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 { - 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.convertToProviderBottomSheetState( diff --git a/features/swap/impl/src/main/java/com/tangem/feature/swap/ui/SwapScreen.kt b/features/swap/impl/src/main/java/com/tangem/feature/swap/ui/SwapScreen.kt index 4cc5d10cd9..337dbe062d 100644 --- a/features/swap/impl/src/main/java/com/tangem/feature/swap/ui/SwapScreen.kt +++ b/features/swap/impl/src/main/java/com/tangem/feature/swap/ui/SwapScreen.kt @@ -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) } } } diff --git a/features/swap/impl/src/main/java/com/tangem/feature/swap/ui/SwapScreenContent.kt b/features/swap/impl/src/main/java/com/tangem/feature/swap/ui/SwapScreenContent.kt index fa11d144da..0e074cbef4 100644 --- a/features/swap/impl/src/main/java/com/tangem/feature/swap/ui/SwapScreenContent.kt +++ b/features/swap/impl/src/main/java/com/tangem/feature/swap/ui/SwapScreenContent.kt @@ -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 = {}, diff --git a/features/swap/impl/src/main/java/com/tangem/feature/swap/ui/SwapSuccessScreen.kt b/features/swap/impl/src/main/java/com/tangem/feature/swap/ui/SwapSuccessScreen.kt index 55f583d61e..b29b7c72c9 100644 --- a/features/swap/impl/src/main/java/com/tangem/feature/swap/ui/SwapSuccessScreen.kt +++ b/features/swap/impl/src/main/java/com/tangem/feature/swap/ui/SwapSuccessScreen.kt @@ -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) diff --git a/features/swap/impl/src/main/java/com/tangem/feature/swap/ui/transfer/SwapTransferStateBuilder.kt b/features/swap/impl/src/main/java/com/tangem/feature/swap/ui/transfer/SwapTransferStateBuilder.kt index 14ab1bb126..5665834d36 100644 --- a/features/swap/impl/src/main/java/com/tangem/feature/swap/ui/transfer/SwapTransferStateBuilder.kt +++ b/features/swap/impl/src/main/java/com/tangem/feature/swap/ui/transfer/SwapTransferStateBuilder.kt @@ -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 = {}, + ), + ) + } } \ No newline at end of file diff --git a/features/swap/impl/src/test/java/com/tangem/feature/swap/DefaultInitialCurrenciesResolverTest.kt b/features/swap/impl/src/test/java/com/tangem/feature/swap/DefaultInitialCurrenciesResolverTest.kt index 3f217d42d1..dc231612df 100644 --- a/features/swap/impl/src/test/java/com/tangem/feature/swap/DefaultInitialCurrenciesResolverTest.kt +++ b/features/swap/impl/src/test/java/com/tangem/feature/swap/DefaultInitialCurrenciesResolverTest.kt @@ -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( diff --git a/features/swap/impl/src/test/java/com/tangem/feature/swap/StateBuilderInitialStateTest.kt b/features/swap/impl/src/test/java/com/tangem/feature/swap/StateBuilderInitialStateTest.kt index 4c40dcfb92..7a96817e47 100644 --- a/features/swap/impl/src/test/java/com/tangem/feature/swap/StateBuilderInitialStateTest.kt +++ b/features/swap/impl/src/test/java/com/tangem/feature/swap/StateBuilderInitialStateTest.kt @@ -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`() { diff --git a/features/swap/impl/src/test/java/com/tangem/feature/swap/StateBuilderPairsTest.kt b/features/swap/impl/src/test/java/com/tangem/feature/swap/StateBuilderPairsTest.kt index 833a71b7cf..ba6ee3e353 100644 --- a/features/swap/impl/src/test/java/com/tangem/feature/swap/StateBuilderPairsTest.kt +++ b/features/swap/impl/src/test/java/com/tangem/feature/swap/StateBuilderPairsTest.kt @@ -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`() { diff --git a/features/swap/impl/src/test/java/com/tangem/feature/swap/StateBuilderQuotesTest.kt b/features/swap/impl/src/test/java/com/tangem/feature/swap/StateBuilderQuotesTest.kt index badfe8f5af..e69de29bb2 100644 --- a/features/swap/impl/src/test/java/com/tangem/feature/swap/StateBuilderQuotesTest.kt +++ b/features/swap/impl/src/test/java/com/tangem/feature/swap/StateBuilderQuotesTest.kt @@ -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 = mockk() - private val appCurrencyProvider: Provider = mockk() - private val isAccountsModeProvider: Provider = 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, - ) - } -} \ No newline at end of file diff --git a/features/swap/impl/src/test/java/com/tangem/feature/swap/StateBuilderSwapDataTest.kt b/features/swap/impl/src/test/java/com/tangem/feature/swap/StateBuilderSwapDataTest.kt index ffeb69d172..e69de29bb2 100644 --- a/features/swap/impl/src/test/java/com/tangem/feature/swap/StateBuilderSwapDataTest.kt +++ b/features/swap/impl/src/test/java/com/tangem/feature/swap/StateBuilderSwapDataTest.kt @@ -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 = mockk() - private val appCurrencyProvider: Provider = mockk() - private val isAccountsModeProvider: Provider = 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(), - ) - } -} \ No newline at end of file diff --git a/features/swap/impl/src/test/java/com/tangem/feature/swap/ui/transfer/SwapTransferStateBuilderTest.kt b/features/swap/impl/src/test/java/com/tangem/feature/swap/ui/transfer/SwapTransferStateBuilderTest.kt index 2a254964f3..c9165e2f2a 100644 --- a/features/swap/impl/src/test/java/com/tangem/feature/swap/ui/transfer/SwapTransferStateBuilderTest.kt +++ b/features/swap/impl/src/test/java/com/tangem/feature/swap/ui/transfer/SwapTransferStateBuilderTest.kt @@ -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, ),