Updated on 2026-08-14
This commit is contained in:
parent
470fc8c140
commit
3531c95908
67 changed files with 6736 additions and 5566 deletions
|
|
@ -6,49 +6,56 @@ Token-to-token exchange feature. Users select FROM and TO tokens, get quotes fro
|
|||
|
||||
```
|
||||
features/swap/
|
||||
api/ — Public contracts (SwapComponent, SwapEntryComponent, SwapFeatureToggles)
|
||||
api/ — Public contracts (SwapComponent, SwapFeatureToggles)
|
||||
impl/ — UI, model, navigation, DI, token selection subfeature
|
||||
domain/ — Business logic (SwapInteractor) + domain models
|
||||
api/ — Domain interfaces
|
||||
api/ — Domain interfaces (SwapRepository)
|
||||
models/ — Domain model types (SwapPair, SwapProvider, SwapState, etc.)
|
||||
fee/ — Fee calculation package (see Fee Architecture below)
|
||||
data/ — Repository implementations, Retrofit APIs, Moshi DTOs
|
||||
```
|
||||
|
||||
**Package naming:** API = `com.tangem.features.swap`, Impl = `com.tangem.feature.swap` (singular `feature`, legacy inconsistency).
|
||||
|
||||
**Build commands:**
|
||||
```bash
|
||||
./gradlew :features:swap:impl:compileDebugKotlin
|
||||
./gradlew :features:swap:api:compileDebugKotlin
|
||||
./gradlew :features:swap:domain:compileDebugKotlin
|
||||
./gradlew :features:swap:domain:test
|
||||
./gradlew :features:swap:impl:detekt
|
||||
```
|
||||
|
||||
## Key Components
|
||||
|
||||
### SwapComponent (API)
|
||||
Entry point. `Params` requires `currencyFrom`, `userWalletId`, `screenSource`. Optional: `currencyTo`, `isInitialReverseOrder`, `tangemPayInput`, `preselectedToToken`, `preselectedAccount`.
|
||||
Entry point. `Params` requires `userWalletId`, optional `cryptoCurrency`, `screenSource`, `currencyPosition` (`FROM`/`TO`/`ANY`), and `tangemPayInput`.
|
||||
|
||||
### SwapEntryComponent (API)
|
||||
Gateway component with sealed `Params`: `Story`, `Empty`, `Selected`, `Payment`. Routes to stories or directly to swap based on input type. See `entry/SwapEntryRoute.kt` for route definitions.
|
||||
File: `features/swap/api/src/main/kotlin/com/tangem/features/swap/SwapComponent.kt`
|
||||
|
||||
### DefaultSwapComponent (impl)
|
||||
Decompose component. Creates `SwapModel` via `getOrCreateModel(params)`.
|
||||
|
||||
**Child navigation:**
|
||||
- `childStack(SwapRoute)` for screen navigation — `SwapRoute.Main`, `SwapRoute.Success`, `SwapRoute.SelectToken(isFromDirection)` rendered via `Children` composable with fade animation
|
||||
- `SlotNavigation<Unit>` for approval bottom sheet (`GiveApprovalComponent`)
|
||||
- `SlotNavigation<FeeSelectorConfig>` for fee selector block
|
||||
- `childStack(SwapRoute)` — `SwapRoute.Main`, `SwapRoute.Success`, `SwapRoute.SelectToken(isFromDirection)`, rendered via `Children` with fade animation
|
||||
- `SlotNavigation<Unit>` — approval bottom sheet (`GiveApprovalComponent`)
|
||||
- `SlotNavigation<FeeSelectorConfig>` — fee selector block
|
||||
|
||||
**Injected factories:** `SwapFeeSelectorBlockComponent.Factory`, `GiveApprovalComponent.Factory`, `ChooseTokenComponent.Factory`.
|
||||
|
||||
File: `features/swap/impl/src/main/java/com/tangem/feature/swap/DefaultSwapComponent.kt`
|
||||
|
||||
### SwapModel (impl)
|
||||
`@ModelScoped`, extends `Model()`. The central coordinator — ~1500 lines.
|
||||
`@ModelScoped`, extends `Model()`. Central coordinator — ~2100 lines.
|
||||
|
||||
**Key state:**
|
||||
- `dataStateStateFlow: MutableStateFlow<SwapProcessDataState>` — reactive domain data (from/to tokens, pairs, providers, amounts, fees)
|
||||
- `uiState: SwapStateHolder by mutableStateOf()` — Compose UI state built by `StateBuilder`
|
||||
- `feeSelectorRepository: FeeSelectorRepository` — fee state management
|
||||
- `feeSelectorRepository: FeeSelectorRepository` — inner class that implements `SwapFeeSelectorBlockComponent.ModelRepositoryExtended`; wires the fee selector UI component to `SwapInteractor.loadSwapFee` and `SwapInteractor.applySwapFee`
|
||||
- `stackNavigation: StackNavigation<SwapRoute>` — stack navigation exposed from `SwapRouter`
|
||||
- `approvalSlotNavigation: SlotNavigation<Unit>` — approval bottom sheet
|
||||
|
||||
**Navigation:**
|
||||
- `SwapRouter` wraps `AppRouter` + `StackNavigation<SwapRoute>` for screen switching and back navigation
|
||||
- `swapRouter.openScreen(SwapRoute.SelectToken(isFromDirection))` to push token selection
|
||||
- `swapRouter.openScreen(SwapRoute.Success)` replaces current with success screen
|
||||
- `swapRouter.back()` — pops local stack or exits swap via AppRouter
|
||||
File: `features/swap/impl/src/main/java/com/tangem/feature/swap/model/SwapModel.kt`
|
||||
|
||||
**Initialization flow (init block):**
|
||||
1. Subscribes to `chooseTokenBridge.onCurrencyChosen` → `onTokenSelect(result)`
|
||||
|
|
@ -69,13 +76,26 @@ Decompose component. Creates `SwapModel` via `getOrCreateModel(params)`.
|
|||
3. On approval done → reloads quotes
|
||||
4. On swap success → `swapRouter.openScreen(SwapRoute.Success)`
|
||||
|
||||
### SwapProcessDataState (impl)
|
||||
Data class holding the live domain state for the current swap session.
|
||||
|
||||
Key fields: `fromSwapCurrencyStatus`, `toSwapCurrencyStatus`, `feePaidCryptoCurrency`, `pairs: List<SwapPairLeast>`, `selectedProvider`, `lastLoadedSwapStates: Map<SwapProvider, SwapState>`, `swapDataModel: SwapDataModel?`, `amount: String?`, `reduceBalanceBy`.
|
||||
|
||||
`getCurrentLoadedSwapState()` — convenience to get `lastLoadedSwapStates[selectedProvider] as? QuotesLoadedState`.
|
||||
|
||||
File: `features/swap/impl/src/main/java/com/tangem/feature/swap/model/SwapProcessDataState.kt`
|
||||
|
||||
### StateBuilder (impl)
|
||||
Pure transformation class. Takes `UiActions` + providers, builds `SwapStateHolder` from `SwapProcessDataState`.
|
||||
|
||||
Key methods: `createInitialLoadingState`, `createQuotesLoadedState`, `createSuccessState`, `loadingPermissionState`, `updateSwapAmount`, `addNotification`, `dismissBottomSheet`.
|
||||
|
||||
File: `features/swap/impl/src/main/java/com/tangem/feature/swap/ui/StateBuilder.kt`
|
||||
|
||||
### SwapRouter (impl)
|
||||
Wraps `AppRouter` + `StackNavigation<SwapRoute>`. Handles `openScreen(SwapRoute)` to push/replace stack entries and `back()` with special logic: SelectToken pops local stack, Success exits to screen before SwapCrypto in app stack, Main pops AppRouter. `openTokenDetails()` navigates to `AppRoute.CurrencyDetails`.
|
||||
Wraps `AppRouter` + `StackNavigation<SwapRoute>`. `openScreen(SwapRoute)` pushes/replaces stack entries. `back()` has special logic: SelectToken pops local stack, Success exits to the screen before SwapCrypto in the app stack, Main pops AppRouter. `openTokenDetails()` navigates to `AppRoute.CurrencyDetails`.
|
||||
|
||||
File: `features/swap/impl/src/main/java/com/tangem/feature/swap/router/SwapRoute.kt`
|
||||
|
||||
## Token Selection Subfeature (impl)
|
||||
|
||||
|
|
@ -88,40 +108,148 @@ Self-contained within `choosetoken/` package:
|
|||
|
||||
## Domain Layer
|
||||
|
||||
### SwapInteractor
|
||||
Central domain interface. Methods:
|
||||
- `getPair(from, to, filterProviderTypes)` → `Either<ExpressError, List<SwapPairLeast>>`
|
||||
- `findBestQuote(from, to, providers, amount, ...)` → `Map<SwapProvider, SwapState>`
|
||||
- `onSwap(from, to, provider, swapData, amount, fee, ...)` → `SwapTransactionState`
|
||||
- `loadFeeForSwapTransaction(...)` → `Either<GetFeeError, TransactionFee/TransactionFeeExtended>`
|
||||
- `getInitialCurrencyToSwap(accountStatusList, fromUserWallet, isReverse)` → `AccountCryptoCurrencyStatus?`
|
||||
- `getTokenBalance(token)` → `SwapAmount`
|
||||
### SwapInteractor (interface)
|
||||
|
||||
### Key Domain Models
|
||||
- `SwapPairLeast` — from/to token info + providers list
|
||||
- `SwapProvider` — providerId, name, type (DEX/CEX/DEX_BRIDGE), rates, slippage, TOS links
|
||||
- `SwapState` — sealed: `QuotesLoadedState`, `SwapError`, `EmptyAmountState`
|
||||
- `SwapCurrencyStatus` — wraps `CryptoCurrencyStatus` + `UserWallet` + `Account`
|
||||
- `SwapAmount` — value + decimals pair
|
||||
- `SwapDataModel` — quote result with transaction data
|
||||
File: `features/swap/domain/src/main/java/com/tangem/feature/swap/domain/SwapInteractor.kt`
|
||||
|
||||
All public methods:
|
||||
- `getPair(from, to, filterProviderTypes)` → `Either<ExpressError, List<SwapPairLeast>>`
|
||||
- `findProvidersForPair(from, to, pairs)` → `List<SwapProvider>`
|
||||
- `findProvidersForPairWithCheck(from, to, pairs)` → `List<SwapProvider>` (checks asset requirements/FCA)
|
||||
- `findBestQuote(from, to, providers, amount, reduceBalanceBy)` → `Map<SwapProvider, SwapState>` (parallel per-provider)
|
||||
- `onSwap(from, to, provider, swapData, amount, includeFeeInAmount, fee, operationType, isTangemPayWithdrawal)` → `SwapTransactionState`
|
||||
- `loadSwapFee(provider, fromStatus, toStatus, amount, swapData, selectedFeeToken)` → `Either<GetFeeError, SwapFee>` — unified fee entry point (see Fee Architecture)
|
||||
- `applySwapFee(state: QuotesLoadedState, fee: SwapFee)` → `QuotesLoadedState` — patches balance checks without re-fetching quotes
|
||||
- `getTokenBalance(token)` → `SwapAmount`
|
||||
- `getNativeToken(swapCurrencyStatus)` → `CryptoCurrency`
|
||||
- `storeSwapTransaction(...)` — persists transaction for status tracking
|
||||
|
||||
### SwapInteractorImpl (impl)
|
||||
|
||||
File: `features/swap/domain/src/main/java/com/tangem/feature/swap/domain/SwapInteractorImpl.kt`
|
||||
|
||||
`@Inject` constructor with ~28 dependencies. Key injected components:
|
||||
- `dexSwapFeeCalculator: DexSwapFeeCalculator` — fee calculation for DEX/DEX_BRIDGE
|
||||
- `cexSwapFeeCalculator: CexSwapFeeCalculator` — fee calculation for CEX
|
||||
|
||||
`findBestQuote` dispatches per-provider using `supervisorScope + async`:
|
||||
- `ExchangeProviderType.DEX` / `DEX_BRIDGE` → `manageDex(...)` or `manageDexSolana(...)`
|
||||
- `ExchangeProviderType.CEX` → `manageCex(...)`
|
||||
|
||||
For DEX (non-Solana): if allowance OK and balance sufficient → `loadDexSwapDataNoFee(...)` which fetches exchange data but sets `feeState = NotEnough()` transiently. Fee is applied later via `applySwapFee`.
|
||||
|
||||
`onSwap` dispatch:
|
||||
- CEX → `onSwapCex(...)` — fetches exchange data, then either `createAndSendGaslessTransactionUseCase` (token fee) or `sendTransactionUseCase` (native fee)
|
||||
- DEX non-Solana → `onSwapDex(...)` — `createTransactionUseCase` with `createDexTxExtras(..., gasLimit = fee.fee.getGasLimit())`
|
||||
- DEX Solana → compiled tx signed as-is; `fee` is only used for analytics/UI
|
||||
|
||||
### SwapTransferInteractor / SwapTransferInteractorImpl (domain)
|
||||
|
||||
Handles within-wallet transfers (same-wallet, same-account coin moves). `shouldTransferInsteadOfSwap(from, to)` detects same-wallet same-currency pairs. `updateTransfer(from, to, amount)` returns a `SwapState.Transfer` (not a quote). No fee calculation involved.
|
||||
|
||||
Files:
|
||||
- `features/swap/domain/src/main/java/com/tangem/feature/swap/domain/transfer/SwapTransferInteractor.kt`
|
||||
- `features/swap/domain/src/main/java/com/tangem/feature/swap/domain/transfer/SwapTransferInteractorImpl.kt`
|
||||
|
||||
## Fee Architecture (post [REDACTED_TASK_KEY] refactor)
|
||||
|
||||
The fee subsystem was fully redesigned across three tickets ([REDACTED_TASK_KEY], [REDACTED_TASK_KEY], [REDACTED_TASK_KEY], [REDACTED_TASK_KEY]). All legacy `loadFeeForSwapTransaction`, `loadFeeForDex`, `getFeeForCex` overloads have been **removed**. The current design:
|
||||
|
||||
### Class Hierarchy
|
||||
|
||||
```
|
||||
SwapInteractor.loadSwapFee() ← unified entry point (Phase 3)
|
||||
├─ DEX/DEX_BRIDGE → DexSwapFeeCalculator.calculate() → DexFeeResult
|
||||
│ ├─ Solana path: TransactionData.Compiled (no gas bump)
|
||||
│ └─ EVM path: TransactionData.Uncompiled + patchEthGasLimitForSwap(DEX_PERCENTAGE=112)
|
||||
│ └─ fallback: GetEthSpecificFeeUseCase on IllegalStateException
|
||||
└─ CEX → CexSwapFeeCalculator.calculate() → CexFeeResult
|
||||
├─ selectedFeeToken == null → EstimateFeeForGaslessTxUseCase (no gas bump)
|
||||
├─ selectedFeeToken is Token → EstimateFeeForTokenUseCase (no gas bump)
|
||||
└─ selectedFeeToken is Coin → EstimateFeeUseCase + patchEthGasLimitForSwap(SEND_PERCENTAGE=105)
|
||||
|
||||
SwapFeeFactory.from(transactionFeeResult, selectedFeeToken, otherNativeFee, feeBucket)
|
||||
→ SwapFee (the single fee carrier used everywhere downstream)
|
||||
|
||||
SwapInteractor.applySwapFee(state, fee) ← patches QuotesLoadedState (Phase 4)
|
||||
→ recomputes balanceStatus: SwapBalanceStatus (`Pending` / `Sufficient` / `FeeAdjustedAmount` / `InsufficientAmount` / `InsufficientFee`), currencyCheck, validationResult
|
||||
```
|
||||
|
||||
### Key Types
|
||||
|
||||
| Type | File | Purpose |
|
||||
|------|------|---------|
|
||||
| `SwapFee` | `domain/models/ui/SwapFee.kt` | Unified carrier: `fee: Fee`, `transactionFeeResult: TransactionFeeResult`, `selectedFeeToken: CryptoCurrencyStatus`, `otherNativeFee: BigDecimal`, `feeBucket: FeeBucket` |
|
||||
| `FeeBucket` | `domain/models/ui/FeeBucket.kt` | `SLOW/MARKET/FAST/SUGGESTED/CUSTOM`; `toAnalyticsName()` replaces legacy `FeeType.getNameForAnalytics()` |
|
||||
| `TransactionFeeResult` | `domain/fee/TransactionFeeResult.kt` | Sealed: `Loaded(TransactionFee)` for native, `LoadedExtended(TransactionFeeExtended)` for gasless/token |
|
||||
| `DexFeeResult` | `domain/fee/DexFeeResult.kt` | `transactionFee`, `otherNativeFee`, `gas: BigInteger?` |
|
||||
| `CexFeeResult` | `domain/fee/CexFeeResult.kt` | `transactionFee: TransactionFeeResult` |
|
||||
| `DexSwapFeeCalculator` | `domain/fee/DexSwapFeeCalculator.kt` | Solana vs EVM branching, 12% gas bump |
|
||||
| `CexSwapFeeCalculator` | `domain/fee/CexSwapFeeCalculator.kt` | gasless/token/native branching, 5% gas bump |
|
||||
| `SwapFeeFactory` | `domain/fee/SwapFeeFactory.kt` | `fromLoaded`, `fromLoadedExtended`, `from` (polymorphic) + `selectFee` for bucket picking |
|
||||
| `PatchEthGasLimitForSwap` | `domain/fee/PatchEthGasLimitForSwap.kt` | Multiplies ETH gas limit. `DEX_PERCENTAGE=112`, `SEND_PERCENTAGE=105` |
|
||||
|
||||
### DI for Fee Classes
|
||||
|
||||
Two `PatchEthGasLimitForSwap` instances with `@Qualifier`:
|
||||
- `@SwapDexGasLimit` → `DEX_PERCENTAGE=112` → injected into `DexSwapFeeCalculator`
|
||||
- `@SwapSendGasLimit` → `SEND_PERCENTAGE=105` → injected into `CexSwapFeeCalculator`
|
||||
|
||||
Qualifiers: `features/swap/domain/src/main/java/com/tangem/feature/swap/domain/di/SwapFeeQualifiers.kt`
|
||||
Bindings: `features/swap/domain/src/main/java/com/tangem/feature/swap/domain/di/SwapDomainModule.kt`
|
||||
|
||||
### Fee Selector Wiring (SwapModel.FeeSelectorRepository)
|
||||
|
||||
`SwapModel` contains an inner class `FeeSelectorRepository` that implements `SwapFeeSelectorBlockComponent.ModelRepositoryExtended`. This is the bridge between the send-v2 fee selector UI component and the swap domain:
|
||||
|
||||
- `loadFeeExtended(selectedToken)` → calls `swapInteractor.loadSwapFee(...)`, wraps result as `TransactionFeeExtended` for the fee selector block
|
||||
- `loadFee()` → same path, extracts `TransactionFee` from the `SwapFee` result
|
||||
- `onResult(newState: FeeSelectorUM)` → when fee selector emits `Content`, calls `swapInteractor.applySwapFee(currentQuotesLoadedState, swapFee)` and updates `dataState.lastLoadedSwapStates`
|
||||
|
||||
DEX path requires a pre-fetched `swapDataModel` (populated by `loadDexSwapDataNoFee`). CEX passes `swapData = null`.
|
||||
|
||||
`FeeItem` → `FeeBucket` mapping lives at `SwapModel.FeeItem.toFeeBucket()` (line ~1921).
|
||||
|
||||
`getSelectedSwapFee()` (line ~1882) — reconstructs a `SwapFee` from `feeSelectorRepository.state.value as FeeSelectorUM.Content`.
|
||||
|
||||
### otherNativeFee (DEX bridge)
|
||||
|
||||
`ExpressTransactionModel.DEX.otherNativeFeeWei` — present only for `DEX_BRIDGE` providers. Converted from Wei in `DexSwapFeeCalculator.calculate()` and propagated as `DexFeeResult.otherNativeFee`. Carried through to `SwapFee.otherNativeFee`.
|
||||
|
||||
`applySwapFee` uses `fee.fee.amount.value + fee.otherNativeFee` as the balance check amount. `resolveOtherNativeFee()` in `SwapModel` reads it from `dataState.swapDataModel.transaction` since `FeeSelectorUM` does not carry it.
|
||||
|
||||
## Key Domain Models
|
||||
|
||||
- `SwapState` (sealed) — `QuotesLoadedState`, `Transfer`, `EmptyAmountState`, `SwapError`
|
||||
- `QuotesLoadedState` carries `preparedSwapConfigState: PreparedSwapConfigState` (balance checks, fee state, includeFeeInAmount), `permissionState`, `swapDataModel`, `currencyCheck`, `validationResult`, `minAdaValue`, `swapProvider`
|
||||
- `SwapProvider` — `providerId`, `name`, `type: ExchangeProviderType` (DEX/CEX/DEX_BRIDGE), rates, slippage, TOS links
|
||||
- `SwapPairLeast` — from/to `LeastTokenInfo` (contractAddress + networkId) + `providers: List<SwapProvider>`
|
||||
- `SwapDataModel` — quote result with `transaction: ExpressTransactionModel` (sealed: `DEX`, `CEX`)
|
||||
- `SwapAmount` — `value: BigDecimal` + `decimals: Int`
|
||||
- `TokenSwapInfo` — `tokenAmount: SwapAmount`, `amountFiat: BigDecimal`, `swapCurrencyStatus: SwapCurrencyStatus`
|
||||
|
||||
File locations:
|
||||
- `features/swap/domain/src/main/java/com/tangem/feature/swap/domain/models/ui/SwapState.kt`
|
||||
- `features/swap/domain/src/main/java/com/tangem/feature/swap/domain/models/domain/SwapDataModel.kt`
|
||||
- `features/swap/domain/src/main/java/com/tangem/feature/swap/domain/models/domain/SwapFeeState.kt`
|
||||
|
||||
## DI Modules
|
||||
|
||||
| Module | Scope | Bindings |
|
||||
|--------|-------|----------|
|
||||
| Module | Scope | Purpose |
|
||||
|--------|-------|---------|
|
||||
| `SwapFeatureModule` | Singleton | `SwapComponent.Factory`, `SwapFeatureToggles` |
|
||||
| `SwapModelModule` | ModelComponent | `SwapModel` into model map |
|
||||
| `SwapEntryModule` | Singleton + Model | `SwapEntryComponent.Factory`, `SwapEntryModel` |
|
||||
| `ChooseTokenModule` | Singleton + Model | `ChooseTokenComponent.Factory`, `ChooseTokenBridge.Factory`, `ChooseTokenModel` |
|
||||
| `SwapSingletonModule` | Singleton | `AmountFormatter` |
|
||||
| `SwapDomainModule` | Singleton | `DexSwapFeeCalculator`, `CexSwapFeeCalculator`, two `PatchEthGasLimitForSwap` instances with qualifiers |
|
||||
| `SwapDomainBindModule` | Singleton | `SwapInteractor` → `SwapInteractorImpl`, `SwapTransferInteractor` → `SwapTransferInteractorImpl` |
|
||||
|
||||
## UI Layer
|
||||
## Analytics
|
||||
|
||||
- `SwapScreen` — main swap composable (send card, receive card, swap button, provider, notifications, fee)
|
||||
- `SwapSuccessScreen` — post-swap success with transaction details
|
||||
- `SwapScreenContent` — layout with `ConstraintLayout` for card positioning
|
||||
- `TransactionCard` / `TransactionCardEmpty` — token cards with amount input
|
||||
- Token cards pass `TokenSelectionDirection.FROM` / `.TO` to `onSelectTokenClick`
|
||||
`SwapEvents` sealed class hierarchy at `features/swap/impl/src/main/java/com/tangem/feature/swap/analytics/SwapEvents.kt`.
|
||||
|
||||
Fee tier analytics: `FeeBucket.toAnalyticsName()` → `"Min"/"Normal"/"Max"/"Suggested"/"Custom"`. Maps to `AnalyticsParam.FeeType.fromString(feeBucket.toAnalyticsName())`. The legacy `FeeType.getNameForAnalytics()` extension was removed in Phase 5 of the fee redesign.
|
||||
|
||||
## Navigation Summary
|
||||
|
||||
|
|
@ -138,11 +266,49 @@ AppRouter (global)
|
|||
└─ SwapFeeSelectorBlockComponent (inline fee block)
|
||||
```
|
||||
|
||||
## Build Commands
|
||||
## UI Layer
|
||||
|
||||
```bash
|
||||
./gradlew :features:swap:impl:compileDebugKotlin
|
||||
./gradlew :features:swap:api:compileDebugKotlin
|
||||
./gradlew :features:swap:domain:compileDebugKotlin
|
||||
./gradlew :features:swap:impl:detekt
|
||||
```
|
||||
- `SwapScreen` — main swap composable (send card, receive card, swap button, provider, notifications, fee)
|
||||
- `SwapSuccessScreen` — post-swap success with transaction details
|
||||
- `SwapScreenContent` — layout with `ConstraintLayout` for card positioning
|
||||
- `TransactionCard` / `TransactionCardEmpty` — token cards with amount input
|
||||
- Token cards pass `TokenSelectionDirection.FROM` / `.TO` to `onSelectTokenClick`
|
||||
|
||||
Files: `features/swap/impl/src/main/java/com/tangem/feature/swap/ui/`
|
||||
|
||||
## Testing
|
||||
|
||||
All domain-layer tests use JUnit 5 + MockK + Truth. Base class `SwapInteractorImplTestBase` wires all ~30 `SwapInteractorImpl` dependencies as relaxed mocks and exposes `sut: SwapInteractorImpl` via `lazy`. Tests extend it and stub only what they need.
|
||||
|
||||
Test files by topic:
|
||||
- `SwapInteractorImplTestBase.kt` — base class; also contains `buildSwapCurrencyStatus(...)` and other builders
|
||||
- `SwapInteractorImplLoadSwapFeeTest.kt` — unified `loadSwapFee` (all strategy branches: DEX-EVM, DEX-Solana, DEX bridge, CEX gasless-native, CEX gasless-token, CEX explicit-token, null swapData, zero amount)
|
||||
- `SwapInteractorImplApplySwapFeeTest.kt` — `applySwapFee` balance/fee-state patching
|
||||
- `SwapInteractorImplFindBestQuoteTest.kt` — provider dispatch, balance checks
|
||||
- `SwapInteractorImplLoadDexSwapDataNoFeeTest.kt` — DEX quote-load without fee
|
||||
- `fee/DexSwapFeeCalculatorTest.kt` — DEX calculator (Solana, EVM, gas fallback, bridge fee)
|
||||
- `fee/CexSwapFeeCalculatorTest.kt` — CEX calculator (gasless, token, native)
|
||||
- `fee/SwapFeeFactoryTest.kt` — `SwapFeeFactory` bucket selection
|
||||
- `fee/PatchEthGasLimitForSwapTest.kt` — gas limit bump math
|
||||
- `transfer/SwapTransferInteractorImplTest.kt` — transfer detection and state building
|
||||
- `impl/StateBuilderInitialStateTest.kt`, `StateBuilderPairsTest.kt` — UI state construction
|
||||
|
||||
## Gotchas
|
||||
|
||||
**Fee state is transient on DEX.** `loadDexSwapDataNoFee` returns a `QuotesLoadedState` with `feeState = NotEnough()` and `isBalanceEnough = false`. The real values are only set after the fee selector resolves and calls `applySwapFee`. Do not check `preparedSwapConfigState.isBalanceEnough` before the fee selector has emitted a `FeeSelectorUM.Content` state.
|
||||
|
||||
**`SwapFee` is not carried in `SwapProcessDataState`.** It is reconstructed from `feeSelectorRepository.state.value` via `getSelectedSwapFee()` at each call site (swap execution, analytics). `otherNativeFee` must be re-read from `dataState.swapDataModel.transaction` because `FeeSelectorUM` does not carry it.
|
||||
|
||||
**DEX requires pre-fetched `swapDataModel`.** `FeeSelectorRepository.loadFeeExtended` returns `Left(UnknownError)` when `dataState.swapDataModel == null`. This is by design: `manageDex` only calls `loadDexSwapDataNoFee` (which populates `swapDataModel`) when allowance is OK and balance is sufficient. If the user has insufficient balance or a pending approval, the fee selector will not load.
|
||||
|
||||
**`PatchEthGasLimitForSwap` has two instances with different percentages.** DEX uses 12%, CEX uses 5%. They are distinguished by `@SwapDexGasLimit` and `@SwapSendGasLimit` qualifiers. Passing the wrong qualifier to a calculator is a silent bug with no compile-time check.
|
||||
|
||||
**`Fee.Ethereum.TokenCurrency` throws.** `PatchEthGasLimitForSwap.increaseEthGasLimitInNeeded` calls `error("handle in [REDACTED_TASK_KEY]")` for `TokenCurrency`. This path must not be reached in production. The issue is tracked but not yet resolved.
|
||||
|
||||
**Solana DEX fee is not patched.** Unlike EVM, `DexSwapFeeCalculator` skips `patchEthGasLimitForSwap` for Solana paths. Also: if the compiled transaction exceeds `SOLANA_TRANSACTION_SIZE_THRESHOLD_BYTES` and the wallet is `UserWallet.Cold`, the calculator raises `ExpressDataError.TooLargeSolanaTransactionError`.
|
||||
|
||||
**`TransactionFeeResult` sealed class is not a data class.** `Loaded(val fee: TransactionFee)` and `LoadedExtended(val fee: TransactionFeeExtended)` use regular `class`, so structural equality does not hold. Use `is`-checks and field comparison in tests.
|
||||
|
||||
**`SwapInteractor` interface vs `SwapInteractorImpl`.** The interface exposes `loadSwapFee` and `applySwapFee` (the new unified API). The old `loadFeeForSwapTransaction` overloads (two overloads) and `loadFeeForDex` private method have been fully removed. Do not reference them in new code or tests.
|
||||
|
||||
**Transfer mode vs swap mode.** `SwapTransferInteractor.shouldTransferInsteadOfSwap` detects same-wallet same-currency pairs and returns `true`, causing the UI to show `SwapState.Transfer` instead of `SwapState.QuotesLoadedState`. No fee selector is shown in transfer mode.
|
||||
|
|
@ -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()
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -1,19 +1,16 @@
|
|||
package com.tangem.feature.swap.domain
|
||||
|
||||
import arrow.core.Either
|
||||
import com.tangem.blockchain.common.transaction.TransactionFee
|
||||
import com.tangem.domain.express.models.ExpressError
|
||||
import com.tangem.domain.express.models.ExpressOperationType
|
||||
import com.tangem.domain.models.currency.CryptoCurrency
|
||||
import com.tangem.domain.models.currency.CryptoCurrencyStatus
|
||||
import com.tangem.domain.swap.models.SwapCurrencyStatus
|
||||
import com.tangem.domain.transaction.error.GetFeeError
|
||||
import com.tangem.domain.transaction.models.TransactionFeeExtended
|
||||
import com.tangem.feature.swap.domain.models.SwapAmount
|
||||
import com.tangem.feature.swap.domain.models.domain.*
|
||||
import com.tangem.feature.swap.domain.models.ui.SwapFee
|
||||
import com.tangem.feature.swap.domain.models.ui.SwapState
|
||||
import com.tangem.feature.swap.domain.models.ui.SwapTransactionState
|
||||
import com.tangem.feature.swap.domain.models.ui.TxFee
|
||||
import java.math.BigDecimal
|
||||
|
||||
interface SwapInteractor {
|
||||
|
|
@ -36,7 +33,6 @@ interface SwapInteractor {
|
|||
pairs: List<SwapPairLeast>,
|
||||
): List<SwapProvider>
|
||||
|
||||
@Suppress("LongParameterList")
|
||||
@Throws(IllegalStateException::class)
|
||||
suspend fun findBestQuote(
|
||||
fromSwapCurrencyStatus: SwapCurrencyStatus,
|
||||
|
|
@ -44,9 +40,19 @@ interface SwapInteractor {
|
|||
providers: List<SwapProvider>,
|
||||
amountToSwap: String,
|
||||
reduceBalanceBy: BigDecimal,
|
||||
txFeeSealedState: TxFeeSealedState,
|
||||
): Map<SwapProvider, SwapState>
|
||||
|
||||
/**
|
||||
* Branch selection:
|
||||
* - CEX, native fee → `sendTransactionUseCase`
|
||||
* - CEX, gasless / token fee (`fee.transactionFeeResult is LoadedExtended` and
|
||||
* `fee.selectedFeeToken.currency is CryptoCurrency.Token`) → `createAndSendGaslessTransactionUseCase`
|
||||
|
||||
* - DEX (Solana) → compiled tx signed as-is. `fee` is carried for analytics / UI only.
|
||||
*
|
||||
* @param fee the user-selected fee for the transaction. Required for DEX (non-Solana) and CEX;
|
||||
* may be `null` for Solana DEX and the Tangem Pay withdrawal short-circuit.
|
||||
*/
|
||||
@Suppress("LongParameterList")
|
||||
@Throws(IllegalStateException::class)
|
||||
suspend fun onSwap(
|
||||
|
|
@ -55,12 +61,25 @@ interface SwapInteractor {
|
|||
swapProvider: SwapProvider,
|
||||
swapData: SwapDataModel?,
|
||||
amountToSwap: String,
|
||||
includeFeeInAmount: IncludeFeeInAmount,
|
||||
fee: TxFee?,
|
||||
balanceStatus: SwapBalanceStatus,
|
||||
fee: SwapFee?,
|
||||
expressOperationType: ExpressOperationType,
|
||||
isTangemPayWithdrawal: Boolean,
|
||||
): SwapTransactionState
|
||||
|
||||
/**
|
||||
* Patches an existing [SwapState.QuotesLoadedState] with a freshly resolved [SwapFee] without re-fetching quotes.
|
||||
*
|
||||
* Recomputes `preparedSwapConfigState.balanceStatus`, plus `currencyCheck` and `validationResult`.
|
||||
*
|
||||
* **Idempotent**: applying the same [SwapFee] twice yields an equal state.
|
||||
*/
|
||||
suspend fun applySwapFee(
|
||||
state: SwapState.QuotesLoadedState,
|
||||
fee: SwapFee,
|
||||
lastReducedBalanceBy: BigDecimal,
|
||||
): SwapState.QuotesLoadedState
|
||||
|
||||
/**
|
||||
* Returns token in wallet balance
|
||||
*
|
||||
|
|
@ -68,8 +87,6 @@ interface SwapInteractor {
|
|||
*/
|
||||
fun getTokenBalance(token: CryptoCurrencyStatus): SwapAmount
|
||||
|
||||
suspend fun getNativeToken(swapCurrencyStatus: SwapCurrencyStatus): CryptoCurrency
|
||||
|
||||
@Suppress("LongParameterList")
|
||||
suspend fun storeSwapTransaction(
|
||||
fromSwapCurrencyStatus: SwapCurrencyStatus,
|
||||
|
|
@ -83,19 +100,34 @@ interface SwapInteractor {
|
|||
averageDuration: Int? = null,
|
||||
)
|
||||
|
||||
suspend fun loadFeeForSwapTransaction(
|
||||
fromSwapCurrencyStatus: SwapCurrencyStatus,
|
||||
amount: String,
|
||||
reduceBalanceBy: BigDecimal,
|
||||
/**
|
||||
* Unified swap-fee entry point. Single fee load API used by all providers types (DEX, DEX_BRIDGE, CEX).
|
||||
*
|
||||
* Delegates to `DexSwapFeeCalculator` for DEX/DEX_BRIDGE or to `CexSwapFeeCalculator` for CEX,
|
||||
* then wraps the result in a [SwapFee].
|
||||
*
|
||||
* The DEX path consumes the pre-fetched [swapData] (which carries the `ExpressTransactionModel.DEX` payload);
|
||||
* the CEX path computes the fee directly from `amount`.
|
||||
* When [swapData] is `null` on the DEX path the call short-circuits to `Left(GetFeeError.UnknownError)` —
|
||||
* callers must ensure swap data has resolved before triggering fee load.
|
||||
*
|
||||
* Native-fallback semantics on the CEX gasless path are preserved: when
|
||||
* [selectedFeeToken] is `null`, `EstimateFeeForGaslessTxUseCase` is invoked and chooses
|
||||
* native vs token internally. The returned `SwapFee.selectedFeeToken` is non-null —
|
||||
* resolved from gasless's chosen token or from the native coin status when gasless picked
|
||||
* native.
|
||||
*
|
||||
* @param swapData pre-fetched DEX exchange data; pass `null` for CEX providers.
|
||||
* @param selectedFeeToken the currency the user picked to pay the fee. `null` triggers the
|
||||
* gasless / native-default path on CEX.
|
||||
*/
|
||||
@Suppress("LongParameterList")
|
||||
suspend fun loadSwapFee(
|
||||
provider: SwapProvider,
|
||||
fromStatus: SwapCurrencyStatus,
|
||||
toStatus: SwapCurrencyStatus,
|
||||
amount: SwapAmount,
|
||||
swapData: SwapDataModel?,
|
||||
selectedFeeToken: CryptoCurrencyStatus?,
|
||||
): Either<GetFeeError, TransactionFeeExtended>
|
||||
|
||||
suspend fun loadFeeForSwapTransaction(
|
||||
fromSwapCurrencyStatus: SwapCurrencyStatus,
|
||||
toSwapCurrencyStatus: SwapCurrencyStatus,
|
||||
amount: String,
|
||||
reduceBalanceBy: BigDecimal,
|
||||
provider: SwapProvider,
|
||||
): Either<GetFeeError, TransactionFee>
|
||||
): Either<GetFeeError, SwapFee>
|
||||
}
|
||||
File diff suppressed because it is too large
Load diff
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
@ -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,
|
||||
)
|
||||
|
|
@ -0,0 +1,86 @@
|
|||
package com.tangem.feature.swap.domain.fee
|
||||
|
||||
import arrow.core.Either
|
||||
import arrow.core.raise.either
|
||||
import com.tangem.domain.models.currency.CryptoCurrency
|
||||
import com.tangem.domain.models.currency.CryptoCurrencyStatus
|
||||
import com.tangem.domain.models.wallet.UserWallet
|
||||
import com.tangem.domain.swap.models.SwapCurrencyStatus
|
||||
import com.tangem.domain.transaction.error.GetFeeError
|
||||
import com.tangem.domain.transaction.usecase.EstimateFeeUseCase
|
||||
import com.tangem.domain.transaction.usecase.gasless.EstimateFeeForGaslessTxUseCase
|
||||
import com.tangem.domain.transaction.usecase.gasless.EstimateFeeForTokenUseCase
|
||||
import java.math.BigDecimal
|
||||
|
||||
/**
|
||||
* Calculates the transaction fee for a CEX swap.
|
||||
*
|
||||
* [REDACTED_TASK_KEY] — combines the two existing CEX fee paths in `SwapInteractorImpl` into one place:
|
||||
* - `loadFeeForSwapTransaction` overload 2 (CEX branch, native fee via [EstimateFeeUseCase])
|
||||
* - `loadFeeForSwapTransaction` overload 1 (token/gasless fee via [EstimateFeeForTokenUseCase] or
|
||||
* [EstimateFeeForGaslessTxUseCase])
|
||||
*
|
||||
* Strategy is selected by [selectedFeeToken]:
|
||||
* - `null` → gasless. Calls [EstimateFeeForGaslessTxUseCase] which itself decides whether to use
|
||||
* a native or token fee. **No gas-limit bump is applied** here, matching production behavior of
|
||||
* overload 1.
|
||||
* - non-null + token currency → calls [EstimateFeeForTokenUseCase]. **No gas-limit bump.**
|
||||
* - non-null + native (coin) currency → calls [EstimateFeeUseCase]. **The 5% gas-limit bump is
|
||||
* applied via [patchEthGasLimitForSwap]** for parity with `loadFeeForSwapTransaction` overload 2.
|
||||
* The bump is a no-op for non-Ethereum fees, so this is safe across chains.
|
||||
*
|
||||
* Behavior is byte-for-byte identical to the original methods in `SwapInteractorImpl`. The
|
||||
* original code is intentionally retained alongside this calculator until the caller is migrated
|
||||
* to delegate to it (the migration is deferred — see plan).
|
||||
*/
|
||||
class CexSwapFeeCalculator(
|
||||
private val estimateFeeUseCase: EstimateFeeUseCase,
|
||||
private val estimateFeeForTokenUseCase: EstimateFeeForTokenUseCase,
|
||||
private val estimateFeeForGaslessTxUseCase: EstimateFeeForGaslessTxUseCase,
|
||||
private val patchEthGasLimitForSwap: PatchEthGasLimitForSwap,
|
||||
) {
|
||||
|
||||
suspend fun calculate(
|
||||
userWallet: UserWallet,
|
||||
fromSwapCurrencyStatus: SwapCurrencyStatus,
|
||||
amount: BigDecimal,
|
||||
selectedFeeToken: CryptoCurrencyStatus?,
|
||||
): Either<GetFeeError, CexFeeResult> = either {
|
||||
if (amount.signum() == 0) {
|
||||
raise(GetFeeError.UnknownError)
|
||||
}
|
||||
|
||||
val transactionFeeResult: TransactionFeeResult = when {
|
||||
selectedFeeToken == null -> {
|
||||
// Gasless path — overload 1 in SwapInteractorImpl. No gas-limit bump.
|
||||
val feeExtended = estimateFeeForGaslessTxUseCase(
|
||||
amount = amount,
|
||||
userWallet = userWallet,
|
||||
sendingTokenCurrencyStatus = fromSwapCurrencyStatus.status,
|
||||
).bind()
|
||||
TransactionFeeResult.LoadedExtended(feeExtended)
|
||||
}
|
||||
selectedFeeToken.currency is CryptoCurrency.Token -> {
|
||||
// Explicit gasless-token path — overload 1 in SwapInteractorImpl. No gas-limit bump.
|
||||
val feeExtended = estimateFeeForTokenUseCase(
|
||||
userWallet = userWallet,
|
||||
feeTokenCurrencyStatus = selectedFeeToken,
|
||||
sendingTokenCurrencyStatus = fromSwapCurrencyStatus.status,
|
||||
amount = amount,
|
||||
).bind()
|
||||
TransactionFeeResult.LoadedExtended(feeExtended)
|
||||
}
|
||||
else -> {
|
||||
// Explicit native fee path — overload 2 in SwapInteractorImpl. Apply 5% bump.
|
||||
val fee = estimateFeeUseCase(
|
||||
amount = amount,
|
||||
userWallet = userWallet,
|
||||
cryptoCurrencyStatus = fromSwapCurrencyStatus.status,
|
||||
).bind()
|
||||
TransactionFeeResult.Loaded(patchEthGasLimitForSwap(fee))
|
||||
}
|
||||
}
|
||||
|
||||
CexFeeResult(transactionFee = transactionFeeResult)
|
||||
}
|
||||
}
|
||||
|
|
@ -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?,
|
||||
)
|
||||
|
|
@ -0,0 +1,216 @@
|
|||
package com.tangem.feature.swap.domain.fee
|
||||
|
||||
import android.util.Base64
|
||||
import arrow.core.Either
|
||||
import arrow.core.raise.either
|
||||
import com.tangem.blockchain.blockchains.solana.SolanaTransactionHelper
|
||||
import com.tangem.blockchain.common.Amount
|
||||
import com.tangem.blockchain.common.Blockchain
|
||||
import com.tangem.blockchain.common.TransactionData
|
||||
import com.tangem.blockchain.common.transaction.TransactionFee
|
||||
import com.tangem.blockchainsdk.utils.fromNetworkId
|
||||
import com.tangem.domain.models.currency.CryptoCurrency
|
||||
import com.tangem.domain.models.currency.CryptoCurrencyStatus
|
||||
import com.tangem.domain.models.network.Network
|
||||
import com.tangem.domain.models.wallet.UserWallet
|
||||
import com.tangem.domain.swap.models.SwapCurrencyStatus
|
||||
import com.tangem.domain.transaction.usecase.CreateTransactionDataExtrasUseCase
|
||||
import com.tangem.domain.transaction.usecase.GetEthSpecificFeeUseCase
|
||||
import com.tangem.domain.transaction.usecase.GetFeeUseCase
|
||||
import com.tangem.domain.transaction.usecase.gasless.GetFeeForTokenUseCase
|
||||
import com.tangem.domain.walletmanager.WalletManagersFacade
|
||||
import com.tangem.feature.swap.domain.models.ExpressDataError
|
||||
import com.tangem.feature.swap.domain.models.domain.ExpressTransactionModel
|
||||
import com.tangem.lib.crypto.BlockchainUtils.SOLANA_TRANSACTION_SIZE_THRESHOLD_BYTES
|
||||
import com.tangem.lib.crypto.BlockchainUtils.isSolana
|
||||
import com.tangem.utils.logging.TangemLogger
|
||||
import java.math.BigDecimal
|
||||
|
||||
/**
|
||||
* Calculates the on-chain transaction fee for a DEX swap.
|
||||
*
|
||||
* [REDACTED_TASK_KEY] — extracted verbatim from `SwapInteractorImpl.loadFeeForDex`,
|
||||
* `getFeeDataForDexSwap` and `getFeeDataForSolanaDexSwap` so the DEX-fee strategy is testable in
|
||||
* isolation. The original methods are intentionally retained in `SwapInteractorImpl` until the
|
||||
* caller is migrated to delegate to this calculator (the migration is deferred — see plan).
|
||||
*
|
||||
* Strategy selection mirrors the source: Solana uses [TransactionData.Compiled] from the
|
||||
* Express-supplied `txData` and skips the gas patch; everything else uses
|
||||
* [TransactionData.Uncompiled] and applies the 12% gas-limit bump via [patchEthGasLimitForSwap].
|
||||
*
|
||||
* If [GetFeeUseCase] throws `IllegalStateException` (e.g. payload too large to estimate), the
|
||||
* calculator falls back to [GetEthSpecificFeeUseCase] using the gas value carried by the Express
|
||||
* transaction model — same as the production path.
|
||||
*
|
||||
* @see DexFeeResult for the returned shape.
|
||||
*/
|
||||
@Suppress("LongParameterList")
|
||||
class DexSwapFeeCalculator(
|
||||
private val getFeeUseCase: GetFeeUseCase,
|
||||
private val getEthSpecificFeeUseCase: GetEthSpecificFeeUseCase,
|
||||
private val getFeeForTokenUseCase: GetFeeForTokenUseCase,
|
||||
private val createTransactionExtrasUseCase: CreateTransactionDataExtrasUseCase,
|
||||
private val walletManagersFacade: WalletManagersFacade,
|
||||
private val patchEthGasLimitForSwap: PatchEthGasLimitForSwap,
|
||||
) {
|
||||
|
||||
suspend fun calculate(
|
||||
fromSwapCurrencyStatus: SwapCurrencyStatus,
|
||||
transaction: ExpressTransactionModel.DEX,
|
||||
selectedToken: CryptoCurrencyStatus? = null,
|
||||
): Either<ExpressDataError, DexFeeResult> = either {
|
||||
val networkRawId = fromSwapCurrencyStatus.currency.network.rawId
|
||||
val nativeCoinDecimals = Blockchain.fromNetworkId(networkRawId)?.decimals()
|
||||
?: error("Blockchain not found")
|
||||
val otherNativeFee = transaction.otherNativeFeeWei
|
||||
?.movePointLeft(nativeCoinDecimals)
|
||||
?: BigDecimal.ZERO
|
||||
|
||||
if (isSolana(networkRawId)) {
|
||||
val transactionBytes = Base64.decode(transaction.txData, Base64.NO_WRAP)
|
||||
val formattedHash = getFormattedHash(transactionBytes)
|
||||
|
||||
// TODO Update after new firmware [REDACTED_JIRA]
|
||||
if (formattedHash.size > SOLANA_TRANSACTION_SIZE_THRESHOLD_BYTES &&
|
||||
fromSwapCurrencyStatus.userWallet is UserWallet.Cold
|
||||
) {
|
||||
raise(ExpressDataError.TooLargeSolanaTransactionError())
|
||||
}
|
||||
|
||||
val solanaFee = getFeeDataForSolanaDexSwap(
|
||||
fromSwapCurrencyStatus = fromSwapCurrencyStatus,
|
||||
transactionBytes = transactionBytes,
|
||||
)
|
||||
DexFeeResult(
|
||||
transactionFee = TransactionFeeResult.Loaded(solanaFee),
|
||||
otherNativeFee = otherNativeFee,
|
||||
gas = null,
|
||||
)
|
||||
} else {
|
||||
val rawFeeResult = getFeeDataForDexSwap(
|
||||
fromSwapCurrencyStatus = fromSwapCurrencyStatus,
|
||||
transaction = transaction,
|
||||
selectedToken = selectedToken,
|
||||
).bind()
|
||||
// Apply the 12% bump on EVM, mirroring SwapInteractorImpl.loadFeeForDex.
|
||||
// The original cast `(fee as TransactionFeeResult.Loaded)` only holds when
|
||||
// selectedToken == null; we defensively support LoadedExtended too so the calculator
|
||||
// also handles the gasless-token DEX branch (currently unreachable from production
|
||||
// callers, kept for symmetry with the CEX calculator).
|
||||
val patched: TransactionFeeResult = when (rawFeeResult) {
|
||||
is TransactionFeeResult.Loaded ->
|
||||
TransactionFeeResult.Loaded(patchEthGasLimitForSwap(rawFeeResult.fee))
|
||||
is TransactionFeeResult.LoadedExtended ->
|
||||
TransactionFeeResult.LoadedExtended(
|
||||
rawFeeResult.fee.copy(
|
||||
transactionFee = patchEthGasLimitForSwap(rawFeeResult.fee.transactionFee),
|
||||
),
|
||||
)
|
||||
}
|
||||
DexFeeResult(
|
||||
transactionFee = patched,
|
||||
otherNativeFee = otherNativeFee,
|
||||
gas = transaction.gas,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@Suppress("CyclomaticComplexMethod")
|
||||
private suspend fun getFeeDataForDexSwap(
|
||||
fromSwapCurrencyStatus: SwapCurrencyStatus,
|
||||
transaction: ExpressTransactionModel.DEX,
|
||||
selectedToken: CryptoCurrencyStatus?,
|
||||
): Either<ExpressDataError, TransactionFeeResult> = either {
|
||||
val nativeBalance = walletManagersFacade.getNativeTokenBalance(
|
||||
userWalletId = fromSwapCurrencyStatus.userWalletId,
|
||||
networkId = fromSwapCurrencyStatus.currency.network.rawId,
|
||||
derivationPath = fromSwapCurrencyStatus.currency.network.derivationPath.value,
|
||||
)
|
||||
|
||||
// if native balance is zero - we can't calculate fee
|
||||
if (nativeBalance.signum() == 0) {
|
||||
raise(ExpressDataError.UnknownError())
|
||||
}
|
||||
|
||||
try {
|
||||
val txAmountValue = transaction.txValue ?: error("unable to get txValue")
|
||||
val amountToSend = createNativeAmountForDex(txAmountValue, fromSwapCurrencyStatus.currency.network)
|
||||
|
||||
// transaction.txValue is always native coin
|
||||
if (nativeBalance < amountToSend.value) {
|
||||
error("It's impossible to calculate fee for nativeBalance.value < amountToSend.value")
|
||||
}
|
||||
|
||||
val extras = createTransactionExtrasUseCase(
|
||||
data = transaction.txData,
|
||||
network = fromSwapCurrencyStatus.currency.network,
|
||||
).getOrNull() ?: error("unable to create extras")
|
||||
|
||||
val transactionData = TransactionData.Uncompiled(
|
||||
amount = amountToSend,
|
||||
destinationAddress = transaction.txTo,
|
||||
fee = null,
|
||||
sourceAddress = transaction.txFrom,
|
||||
extras = extras,
|
||||
)
|
||||
if (selectedToken != null && selectedToken.currency is CryptoCurrency.Token) {
|
||||
getFeeForTokenUseCase(
|
||||
transactionData = transactionData,
|
||||
token = selectedToken.currency,
|
||||
userWallet = fromSwapCurrencyStatus.userWallet,
|
||||
).getOrNull()?.let { TransactionFeeResult.LoadedExtended(it) }
|
||||
?: error("unable to calculate fee for token")
|
||||
} else {
|
||||
getFeeUseCase(
|
||||
transactionData = transactionData,
|
||||
network = fromSwapCurrencyStatus.currency.network,
|
||||
userWallet = fromSwapCurrencyStatus.userWallet,
|
||||
).getOrNull()?.let { TransactionFeeResult.Loaded(it) } ?: error("unable to calculate fee")
|
||||
}
|
||||
} catch (_: IllegalStateException) {
|
||||
getEthSpecificFeeUseCase(
|
||||
userWallet = fromSwapCurrencyStatus.userWallet,
|
||||
cryptoCurrency = fromSwapCurrencyStatus.currency,
|
||||
gasLimit = transaction.gas,
|
||||
).getOrNull()?.let { TransactionFeeResult.Loaded(it) }
|
||||
?: raise(ExpressDataError.UnknownError())
|
||||
}
|
||||
}
|
||||
|
||||
private suspend fun getFeeDataForSolanaDexSwap(
|
||||
fromSwapCurrencyStatus: SwapCurrencyStatus,
|
||||
transactionBytes: ByteArray,
|
||||
): TransactionFee {
|
||||
val transactionData = TransactionData.Compiled(
|
||||
value = TransactionData.Compiled.Data.Bytes(transactionBytes),
|
||||
)
|
||||
|
||||
return getFeeUseCase(
|
||||
transactionData = transactionData,
|
||||
network = fromSwapCurrencyStatus.currency.network,
|
||||
userWallet = fromSwapCurrencyStatus.userWallet,
|
||||
).getOrNull() ?: error("unable to calculate fee")
|
||||
}
|
||||
|
||||
private fun createNativeAmountForDex(txValueAmount: String, network: Network): Amount {
|
||||
val nativeDecimals = Blockchain.fromNetworkId(network.rawId)?.decimals()
|
||||
?: error("Blockchain not found")
|
||||
val decimalValue = txValueAmount.toBigDecimalOrNull()?.movePointLeft(nativeDecimals)
|
||||
?: error("txValue parse error")
|
||||
return Amount(
|
||||
currencySymbol = network.currencySymbol,
|
||||
value = decimalValue,
|
||||
decimals = nativeDecimals,
|
||||
)
|
||||
}
|
||||
|
||||
// TODO create usecase [REDACTED_TASK_KEY] (parity with SwapInteractorImpl.getFormattedHash)
|
||||
private fun getFormattedHash(hash: ByteArray): ByteArray {
|
||||
return try {
|
||||
SolanaTransactionHelper.removeSignaturesPlaceholders(hash)
|
||||
} catch (e: Exception) {
|
||||
TangemLogger.e("Failed to format the hash: ${e.message.orEmpty()}", e)
|
||||
hash
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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")
|
||||
}
|
||||
}
|
||||
|
|
@ -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
|
||||
}
|
||||
}
|
||||
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
|
|
@ -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()
|
||||
}
|
||||
|
|
@ -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
|
||||
}
|
||||
|
|
@ -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()
|
||||
}
|
||||
|
|
@ -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"
|
||||
}
|
||||
}
|
||||
|
|
@ -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,
|
||||
)
|
||||
|
|
@ -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"
|
||||
}
|
||||
}
|
||||
)
|
||||
|
|
@ -1,57 +0,0 @@
|
|||
package com.tangem.feature.swap.domain.models.ui
|
||||
|
||||
import com.tangem.domain.models.account.Account
|
||||
import com.tangem.domain.models.currency.CryptoCurrencyStatus
|
||||
import com.tangem.feature.swap.domain.models.domain.CryptoCurrencySwapInfo
|
||||
import com.tangem.feature.swap.domain.models.domain.SwapProvider
|
||||
|
||||
data class TokensDataStateExpress(
|
||||
val fromGroup: CurrenciesGroup,
|
||||
val toGroup: CurrenciesGroup,
|
||||
val allProviders: List<SwapProvider>,
|
||||
) {
|
||||
companion object {
|
||||
val EMPTY = TokensDataStateExpress(
|
||||
fromGroup = CurrenciesGroup(
|
||||
available = emptyList(),
|
||||
unavailable = emptyList(),
|
||||
accountCurrencyList = emptyList(),
|
||||
isAfterSearch = false,
|
||||
),
|
||||
toGroup = CurrenciesGroup(
|
||||
available = emptyList(),
|
||||
unavailable = emptyList(),
|
||||
accountCurrencyList = emptyList(),
|
||||
isAfterSearch = false,
|
||||
),
|
||||
allProviders = emptyList(),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
fun TokensDataStateExpress.getGroupWithReverse(isReverseFromTo: Boolean): CurrenciesGroup {
|
||||
return if (isReverseFromTo) {
|
||||
this.fromGroup
|
||||
} else {
|
||||
this.toGroup
|
||||
}
|
||||
}
|
||||
|
||||
data class CurrenciesGroup(
|
||||
val available: List<CryptoCurrencySwapInfo>,
|
||||
val unavailable: List<CryptoCurrencySwapInfo>,
|
||||
val accountCurrencyList: List<AccountSwapAvailability>,
|
||||
val isAfterSearch: Boolean,
|
||||
)
|
||||
|
||||
data class AccountSwapAvailability(
|
||||
val account: Account,
|
||||
val currencyList: List<AccountSwapCurrency>,
|
||||
)
|
||||
|
||||
data class AccountSwapCurrency(
|
||||
val isAvailable: Boolean,
|
||||
val account: Account,
|
||||
val cryptoCurrencyStatus: CryptoCurrencyStatus,
|
||||
val providers: List<SwapProvider>,
|
||||
)
|
||||
|
|
@ -1,7 +1,14 @@
|
|||
package com.tangem.feature.swap.domain.transfer
|
||||
|
||||
import arrow.core.Either
|
||||
import com.tangem.blockchain.common.transaction.Fee
|
||||
import com.tangem.blockchain.common.transaction.TransactionFee
|
||||
import com.tangem.domain.models.currency.CryptoCurrency
|
||||
import com.tangem.domain.swap.models.SwapCurrencyStatus
|
||||
import com.tangem.domain.transaction.error.GetFeeError
|
||||
import com.tangem.domain.transaction.error.SendTransactionError
|
||||
import com.tangem.domain.transaction.models.TransactionFeeExtended
|
||||
import com.tangem.feature.swap.domain.fee.TransactionFeeResult
|
||||
import com.tangem.feature.swap.domain.models.ui.SwapState
|
||||
|
||||
interface SwapTransferInteractor {
|
||||
|
|
@ -12,5 +19,25 @@ interface SwapTransferInteractor {
|
|||
fromTokenAmount: String,
|
||||
): SwapState
|
||||
|
||||
fun shouldTransferInsteadOfSwap(fromSwapCurrency: CryptoCurrency, toSwapCurrency: CryptoCurrency): Boolean
|
||||
fun shouldTransferInsteadOfSwap(fromSwapCurrency: CryptoCurrency?, toSwapCurrency: CryptoCurrency?): Boolean
|
||||
|
||||
suspend fun loadFee(
|
||||
fromSwapCurrencyStatus: SwapCurrencyStatus,
|
||||
toSwapCurrencyStatus: SwapCurrencyStatus,
|
||||
fromTokenAmount: String,
|
||||
): Either<GetFeeError, TransactionFee>
|
||||
|
||||
suspend fun loadFeeExtended(
|
||||
fromSwapCurrencyStatus: SwapCurrencyStatus,
|
||||
toSwapCurrencyStatus: SwapCurrencyStatus,
|
||||
fromTokenAmount: String,
|
||||
): Either<GetFeeError, TransactionFeeExtended>
|
||||
|
||||
suspend fun sendTransfer(
|
||||
fromSwapCurrencyStatus: SwapCurrencyStatus,
|
||||
toSwapCurrencyStatus: SwapCurrencyStatus,
|
||||
fromTokenAmount: String,
|
||||
fee: Fee,
|
||||
transactionFeeResult: TransactionFeeResult,
|
||||
): Either<SendTransactionError, String>
|
||||
}
|
||||
|
|
@ -1,5 +1,10 @@
|
|||
package com.tangem.feature.swap.domain.transfer
|
||||
|
||||
import arrow.core.Either
|
||||
import arrow.core.left
|
||||
import com.tangem.blockchain.common.TransactionData
|
||||
import com.tangem.blockchain.common.transaction.Fee
|
||||
import com.tangem.blockchain.common.transaction.TransactionFee
|
||||
import com.tangem.core.ui.extensions.stringReference
|
||||
import com.tangem.core.ui.format.bigdecimal.fiat
|
||||
import com.tangem.core.ui.format.bigdecimal.format
|
||||
|
|
@ -10,7 +15,19 @@ import com.tangem.domain.appcurrency.extenstions.unwrap
|
|||
import com.tangem.domain.appcurrency.model.AppCurrency
|
||||
import com.tangem.domain.balancehiding.GetBalanceHidingSettingsUseCase
|
||||
import com.tangem.domain.models.currency.CryptoCurrency
|
||||
import com.tangem.domain.models.currency.CryptoCurrencyStatus
|
||||
import com.tangem.domain.models.wallet.UserWallet
|
||||
import com.tangem.domain.swap.models.SwapCurrencyStatus
|
||||
import com.tangem.domain.transaction.error.GetFeeError
|
||||
import com.tangem.domain.transaction.error.SendTransactionError
|
||||
import com.tangem.domain.transaction.models.TransactionFeeExtended
|
||||
import com.tangem.domain.transaction.usecase.CreateTransferTransactionUseCase
|
||||
import com.tangem.domain.transaction.usecase.GetFeeUseCase
|
||||
import com.tangem.domain.transaction.usecase.SendTransactionUseCase
|
||||
import com.tangem.domain.transaction.usecase.gasless.CreateAndSendGaslessTransactionUseCase
|
||||
import com.tangem.domain.transaction.usecase.gasless.GetFeeForGaslessUseCase
|
||||
import com.tangem.domain.utils.convertToSdkAmount
|
||||
import com.tangem.feature.swap.domain.fee.TransactionFeeResult
|
||||
import com.tangem.feature.swap.domain.models.SwapAmount
|
||||
import com.tangem.feature.swap.domain.models.ui.SwapState
|
||||
import com.tangem.feature.swap.domain.models.ui.TokenSwapInfo
|
||||
|
|
@ -20,11 +37,17 @@ import kotlinx.coroutines.flow.first
|
|||
import java.math.BigDecimal
|
||||
import javax.inject.Inject
|
||||
|
||||
@Suppress("LongParameterList")
|
||||
class SwapTransferInteractorImpl @Inject constructor(
|
||||
private val swapFeatureToggles: SwapFeatureToggles,
|
||||
private val getSelectedAppCurrencyUseCase: GetSelectedAppCurrencyUseCase,
|
||||
private val getBalanceHidingSettingsUseCase: GetBalanceHidingSettingsUseCase,
|
||||
private val isAccountsModeEnabledUseCase: IsAccountsModeEnabledUseCase,
|
||||
private val getFeeUseCase: GetFeeUseCase,
|
||||
private val getFeeForGaslessUseCase: GetFeeForGaslessUseCase,
|
||||
private val createTransferTransactionUseCase: CreateTransferTransactionUseCase,
|
||||
private val sendTransactionUseCase: SendTransactionUseCase,
|
||||
private val createAndSendGaslessTransactionUseCase: CreateAndSendGaslessTransactionUseCase,
|
||||
) : SwapTransferInteractor {
|
||||
|
||||
override suspend fun updateTransfer(
|
||||
|
|
@ -78,8 +101,8 @@ class SwapTransferInteractorImpl @Inject constructor(
|
|||
}
|
||||
|
||||
override fun shouldTransferInsteadOfSwap(
|
||||
fromSwapCurrency: CryptoCurrency,
|
||||
toSwapCurrency: CryptoCurrency,
|
||||
fromSwapCurrency: CryptoCurrency?,
|
||||
toSwapCurrency: CryptoCurrency?,
|
||||
): Boolean {
|
||||
if (swapFeatureToggles.isSwapSwitchToTransferEnabled.not()) return false
|
||||
val isSameCurrency = when {
|
||||
|
|
@ -94,4 +117,113 @@ class SwapTransferInteractorImpl @Inject constructor(
|
|||
}
|
||||
return isSameCurrency
|
||||
}
|
||||
|
||||
override suspend fun loadFee(
|
||||
fromSwapCurrencyStatus: SwapCurrencyStatus,
|
||||
toSwapCurrencyStatus: SwapCurrencyStatus,
|
||||
fromTokenAmount: String,
|
||||
): Either<GetFeeError, TransactionFee> {
|
||||
val amount = fromTokenAmount.parseBigDecimalOrNull() ?: BigDecimal.ZERO
|
||||
val destination = toSwapCurrencyStatus.destinationAddress() ?: return feeDataError(
|
||||
message = "Destination address is null",
|
||||
)
|
||||
|
||||
return getFeeUseCase(
|
||||
amount = amount,
|
||||
destination = destination,
|
||||
userWallet = fromSwapCurrencyStatus.userWallet,
|
||||
cryptoCurrency = fromSwapCurrencyStatus.currency,
|
||||
)
|
||||
}
|
||||
|
||||
override suspend fun loadFeeExtended(
|
||||
fromSwapCurrencyStatus: SwapCurrencyStatus,
|
||||
toSwapCurrencyStatus: SwapCurrencyStatus,
|
||||
fromTokenAmount: String,
|
||||
): Either<GetFeeError, TransactionFeeExtended> {
|
||||
val amount = fromTokenAmount.parseBigDecimalOrNull() ?: BigDecimal.ZERO
|
||||
val destination = toSwapCurrencyStatus.destinationAddress() ?: return feeDataError(
|
||||
message = "Destination address is null",
|
||||
)
|
||||
val userWallet = fromSwapCurrencyStatus.userWallet
|
||||
val currency = fromSwapCurrencyStatus.currency
|
||||
|
||||
val transactionData = createTransferTransactionUseCase(
|
||||
amount = amount.convertToSdkAmount(
|
||||
cryptoCurrencyStatus = fromSwapCurrencyStatus.status,
|
||||
),
|
||||
memo = null,
|
||||
destination = destination,
|
||||
userWalletId = userWallet.walletId,
|
||||
network = currency.network,
|
||||
).getOrNull() ?: return feeDataError("Failed to build transfer transaction")
|
||||
|
||||
return getFeeForGaslessUseCase(
|
||||
userWallet = userWallet,
|
||||
network = currency.network,
|
||||
transactionData = transactionData,
|
||||
)
|
||||
}
|
||||
|
||||
override suspend fun sendTransfer(
|
||||
fromSwapCurrencyStatus: SwapCurrencyStatus,
|
||||
toSwapCurrencyStatus: SwapCurrencyStatus,
|
||||
fromTokenAmount: String,
|
||||
fee: Fee,
|
||||
transactionFeeResult: TransactionFeeResult,
|
||||
): Either<SendTransactionError, String> {
|
||||
val amount = fromTokenAmount.parseBigDecimalOrNull()?.takeIf { it.signum() > 0 }
|
||||
?: return SendTransactionError.DataError("Can't parse fromTokenAmount: $fromTokenAmount").left()
|
||||
val destination = toSwapCurrencyStatus.destinationAddress()
|
||||
?: return SendTransactionError.DataError("Destination address is null").left()
|
||||
val userWallet = fromSwapCurrencyStatus.userWallet
|
||||
val currency = fromSwapCurrencyStatus.currency
|
||||
|
||||
val txData = createTransferTransactionUseCase(
|
||||
amount = amount.convertToSdkAmount(cryptoCurrencyStatus = fromSwapCurrencyStatus.status),
|
||||
fee = fee,
|
||||
memo = null,
|
||||
destination = destination,
|
||||
userWalletId = userWallet.walletId,
|
||||
network = currency.network,
|
||||
).getOrNull() ?: return SendTransactionError.DataError("Failed to build transfer transaction").left()
|
||||
|
||||
return sendTransferForFeeType(
|
||||
userWallet = fromSwapCurrencyStatus.userWallet,
|
||||
cryptoCurrencyStatus = fromSwapCurrencyStatus.status,
|
||||
transactionFeeResult = transactionFeeResult,
|
||||
txData = txData,
|
||||
)
|
||||
}
|
||||
|
||||
private suspend fun sendTransferForFeeType(
|
||||
userWallet: UserWallet,
|
||||
cryptoCurrencyStatus: CryptoCurrencyStatus,
|
||||
transactionFeeResult: TransactionFeeResult,
|
||||
txData: TransactionData,
|
||||
): Either<SendTransactionError, String> {
|
||||
val isToken = cryptoCurrencyStatus.currency is CryptoCurrency.Token
|
||||
val isGaslessToken = isToken && transactionFeeResult is TransactionFeeResult.LoadedExtended
|
||||
return if (isGaslessToken) {
|
||||
createAndSendGaslessTransactionUseCase(
|
||||
transactionData = txData,
|
||||
userWallet = userWallet,
|
||||
fee = transactionFeeResult.fee,
|
||||
)
|
||||
} else {
|
||||
sendTransactionUseCase(
|
||||
txData = txData,
|
||||
userWallet = userWallet,
|
||||
network = cryptoCurrencyStatus.currency.network,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
private fun feeDataError(message: String): Either<GetFeeError, Nothing> {
|
||||
return GetFeeError.DataError(IllegalStateException(message)).left()
|
||||
}
|
||||
|
||||
private fun SwapCurrencyStatus.destinationAddress(): String? {
|
||||
return status.value.networkAddress?.defaultAddress?.value
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,881 @@
|
|||
package com.tangem.feature.swap.domain
|
||||
|
||||
import arrow.core.right
|
||||
import com.google.common.truth.Truth.assertThat
|
||||
import com.tangem.blockchain.common.Amount
|
||||
import com.tangem.blockchain.common.Blockchain
|
||||
import com.tangem.blockchain.common.transaction.Fee
|
||||
import com.tangem.blockchain.common.transaction.TransactionFee
|
||||
import com.tangem.blockchainsdk.utils.toNetworkId
|
||||
import com.tangem.domain.models.currency.CryptoCurrency
|
||||
import com.tangem.domain.models.currency.CryptoCurrencyStatus
|
||||
import com.tangem.domain.tokens.model.FeePaidCurrency
|
||||
import com.tangem.domain.tokens.model.warnings.CryptoCurrencyCheck
|
||||
import com.tangem.feature.swap.domain.fee.TransactionFeeResult
|
||||
import com.tangem.feature.swap.domain.models.SwapAmount
|
||||
import com.tangem.feature.swap.domain.models.domain.ExchangeProviderType
|
||||
import com.tangem.feature.swap.domain.models.domain.PreparedSwapConfigState
|
||||
import com.tangem.feature.swap.domain.models.domain.SwapBalanceStatus
|
||||
import com.tangem.feature.swap.domain.models.ui.*
|
||||
import io.mockk.coEvery
|
||||
import io.mockk.every
|
||||
import io.mockk.mockk
|
||||
import kotlinx.coroutines.test.runTest
|
||||
import org.junit.jupiter.api.BeforeEach
|
||||
import org.junit.jupiter.api.Nested
|
||||
import org.junit.jupiter.api.Test
|
||||
import org.junit.jupiter.api.TestInstance
|
||||
import java.math.BigDecimal
|
||||
|
||||
/**
|
||||
* Matrix-style coverage for [SwapInteractorImpl.applySwapFee] across all combinations of:
|
||||
* - Provider type: DEX / DEX_BRIDGE / CEX
|
||||
* - FeePaidCurrency: Coin / Token / SameCurrency / FeeResource
|
||||
* - from-token shape: Coin vs Token
|
||||
*
|
||||
* KEY INVARIANT ([REDACTED_TASK_KEY]):
|
||||
* "For DEX, fee cannot be subtracted from the swap amount."
|
||||
* → When amount + fee > balance, DEX must return InsufficientFee, never FeeAdjustedAmount.
|
||||
* → CEX returns FeeAdjustedAmount in the same scenario.
|
||||
*/
|
||||
@TestInstance(TestInstance.Lifecycle.PER_CLASS)
|
||||
internal class SwapInteractorImplApplySwapFeeMatrixTest : SwapInteractorImplTestBase() {
|
||||
|
||||
private val ethNetwork = Blockchain.Ethereum.toNetworkId()
|
||||
private val lastReducedBalanceBy = BigDecimal.ZERO
|
||||
|
||||
@BeforeEach
|
||||
fun setup() {
|
||||
// Default stubs that keep all tests alive unless they override:
|
||||
coEvery {
|
||||
getCurrencyCheckUseCase.invoke(
|
||||
userWalletId = any(),
|
||||
currencyStatus = any(),
|
||||
feeCurrencyStatus = any(),
|
||||
amount = any(),
|
||||
fee = any(),
|
||||
feeCurrencyBalanceAfterTransaction = any(),
|
||||
recipientAddress = any(),
|
||||
)
|
||||
} returns buildCurrencyCheck()
|
||||
|
||||
coEvery {
|
||||
validateTransactionUseCase.invoke(
|
||||
amount = any(),
|
||||
fee = any(),
|
||||
memo = any(),
|
||||
destination = any(),
|
||||
userWalletId = any(),
|
||||
network = any(),
|
||||
)
|
||||
} returns Unit.right()
|
||||
|
||||
coEvery { walletManagersFacade.getNativeTokenBalance(any(), any(), any()) } returns BigDecimal("10")
|
||||
coEvery { getFeePaidCryptoCurrencyStatusSyncUseCase.invoke(any(), any()) } returns null.right()
|
||||
coEvery { currenciesRepository.createCoinCurrency(any()) } returns buildCoinCurrency()
|
||||
}
|
||||
|
||||
// =========================================================================
|
||||
// Section A: DEX/CEX asymmetry — the KEY INVARIANT
|
||||
// =========================================================================
|
||||
|
||||
@Nested
|
||||
inner class `DEX vs CEX asymmetry - fee-cannot-deduct invariant` {
|
||||
|
||||
/**
|
||||
* GIVEN ExchangeProviderType.DEX
|
||||
* fromToken is Coin, status.value.amount = 1.1 ETH (isBalanceEnough passes: 1.1 >= 1.0+0.01)
|
||||
* FeePaidCurrency.Coin, walletManagersFacade.getNativeTokenBalance = 1.0 ETH
|
||||
* amount = 1.0 ETH, fee = 0.01 ETH
|
||||
* WHEN applySwapFee runs
|
||||
* THEN balanceStatus == InsufficientFee (NOT FeeAdjustedAmount)
|
||||
*
|
||||
* The DEX invariant: DEX never reduces the amount to include fee.
|
||||
* computeBalanceStatus for DEX/DEX_BRIDGE skips getIncludeFeeInAmountInternal entirely,
|
||||
* then falls to getFeeBalanceState. With nativeBalance=1.0 and amount=1.0:
|
||||
* balanceToCheck = nativeBalance(1.0) - amount(1.0) = 0 ≤ fee(0.01) → InsufficientFee.
|
||||
*
|
||||
* NOTE: fromBalance (status.value.amount) must be > amount+fee so isBalanceEnough()
|
||||
* passes and we reach getFeeBalanceState. The walletManagersFacade balance is what
|
||||
* triggers the InsufficientFee via getFeeBalanceState for the coin case.
|
||||
*/
|
||||
@Test
|
||||
fun `applySwapFee DEX with Coin fee — amount+fee greater than balance returns InsufficientFee (cannot deduct on DEX)`() =
|
||||
runTest {
|
||||
coEvery { currenciesRepository.getFeePaidCurrency(any(), any()) } returns FeePaidCurrency.Coin
|
||||
// Native balance (for fee deduction check) = 1.0 ETH.
|
||||
// After subtracting amount (1.0 ETH), 0 remains which is < fee (0.01 ETH).
|
||||
coEvery {
|
||||
walletManagersFacade.getNativeTokenBalance(any(), any(), any())
|
||||
} returns BigDecimal("1.0")
|
||||
|
||||
// fromBalance must be larger than amount+fee so isBalanceEnough() passes.
|
||||
// status.value.amount = 1.1 ETH: 1.1 >= 1.0+0.01=1.01 → isBalanceEnough=true
|
||||
val state = buildQuotesLoadedState(
|
||||
providerType = ExchangeProviderType.DEX,
|
||||
fromAmount = SwapAmount(BigDecimal("1.0"), 18),
|
||||
isCoin = true,
|
||||
fromBalance = BigDecimal("1.1"),
|
||||
)
|
||||
val fee = buildSwapFeeWithCoinToken(feeValue = BigDecimal("0.01"))
|
||||
|
||||
val result = sut.applySwapFee(state, fee, lastReducedBalanceBy)
|
||||
|
||||
// DEX must NOT return FeeAdjustedAmount — it must return InsufficientFee
|
||||
assertThat(result.preparedSwapConfigState.balanceStatus)
|
||||
.isInstanceOf(SwapBalanceStatus.InsufficientFee::class.java)
|
||||
}
|
||||
|
||||
/**
|
||||
* CEX twin: same native-balance scenario → FeeAdjustedAmount (CEX can include fee in amount).
|
||||
*
|
||||
* GIVEN ExchangeProviderType.CEX
|
||||
* fromToken is Coin, status.value.amount = 1.1 ETH, amount = 1.0 ETH, fee = 0.01 ETH
|
||||
* walletManagersFacade.getNativeTokenBalance = 1.0 ETH
|
||||
* WHEN applySwapFee runs
|
||||
* THEN balanceStatus == FeeAdjustedAmount (CEX auto-reduces amount)
|
||||
*
|
||||
* For CEX, getIncludeFeeInAmountInternal fires:
|
||||
* nativeBalance = 1.0, amount = 1.0, amountWithFee = 1.01 > 1.0 = nativeBalance
|
||||
* AND fee(0.01) < amount(1.0) → Included → FeeAdjustedAmount.
|
||||
*/
|
||||
@Test
|
||||
fun `applySwapFee CEX with Coin fee — amount+fee greater than nativeBalance returns FeeAdjustedAmount (can deduct on CEX)`() =
|
||||
runTest {
|
||||
coEvery { currenciesRepository.getFeePaidCurrency(any(), any()) } returns FeePaidCurrency.Coin
|
||||
// Native balance for fee calculation path
|
||||
coEvery {
|
||||
walletManagersFacade.getNativeTokenBalance(any(), any(), any())
|
||||
} returns BigDecimal("1.0")
|
||||
|
||||
// fromBalance (status.value.amount) must pass isBalanceEnough for CEX too
|
||||
val state = buildQuotesLoadedState(
|
||||
providerType = ExchangeProviderType.CEX,
|
||||
fromAmount = SwapAmount(BigDecimal("1.0"), 18),
|
||||
isCoin = true,
|
||||
fromBalance = BigDecimal("1.1"),
|
||||
)
|
||||
val fee = buildSwapFeeWithCoinToken(feeValue = BigDecimal("0.01"))
|
||||
|
||||
val result = sut.applySwapFee(state, fee, lastReducedBalanceBy)
|
||||
|
||||
assertThat(result.preparedSwapConfigState.balanceStatus)
|
||||
.isInstanceOf(SwapBalanceStatus.FeeAdjustedAmount::class.java)
|
||||
}
|
||||
|
||||
/**
|
||||
* DEX_BRIDGE mirrors DEX: same nativeBalance scenario returns InsufficientFee.
|
||||
*/
|
||||
@Test
|
||||
fun `applySwapFee DEX_BRIDGE with Coin fee — amount+fee greater than balance returns InsufficientFee`() =
|
||||
runTest {
|
||||
coEvery { currenciesRepository.getFeePaidCurrency(any(), any()) } returns FeePaidCurrency.Coin
|
||||
coEvery {
|
||||
walletManagersFacade.getNativeTokenBalance(any(), any(), any())
|
||||
} returns BigDecimal("1.0")
|
||||
|
||||
val state = buildQuotesLoadedState(
|
||||
providerType = ExchangeProviderType.DEX_BRIDGE,
|
||||
fromAmount = SwapAmount(BigDecimal("1.0"), 18),
|
||||
isCoin = true,
|
||||
fromBalance = BigDecimal("1.1"),
|
||||
)
|
||||
val fee = buildSwapFeeWithCoinToken(feeValue = BigDecimal("0.01"))
|
||||
|
||||
val result = sut.applySwapFee(state, fee, lastReducedBalanceBy)
|
||||
|
||||
assertThat(result.preparedSwapConfigState.balanceStatus)
|
||||
.isInstanceOf(SwapBalanceStatus.InsufficientFee::class.java)
|
||||
}
|
||||
|
||||
/**
|
||||
* DEX happy path: balance comfortably covers both amount and fee.
|
||||
* Must return Sufficient, not FeeAdjustedAmount.
|
||||
*/
|
||||
@Test
|
||||
fun `applySwapFee DEX with Coin fee — balance covers amount+fee returns Sufficient`() = runTest {
|
||||
coEvery { currenciesRepository.getFeePaidCurrency(any(), any()) } returns FeePaidCurrency.Coin
|
||||
coEvery {
|
||||
walletManagersFacade.getNativeTokenBalance(any(), any(), any())
|
||||
} returns BigDecimal("2.0")
|
||||
|
||||
val state = buildQuotesLoadedState(
|
||||
providerType = ExchangeProviderType.DEX,
|
||||
fromAmount = SwapAmount(BigDecimal("1.0"), 18),
|
||||
isCoin = true,
|
||||
fromBalance = BigDecimal("2.0"),
|
||||
)
|
||||
val fee = buildSwapFeeWithCoinToken(feeValue = BigDecimal("0.01"))
|
||||
|
||||
val result = sut.applySwapFee(state, fee, lastReducedBalanceBy)
|
||||
|
||||
assertThat(result.preparedSwapConfigState.balanceStatus)
|
||||
.isInstanceOf(SwapBalanceStatus.Sufficient::class.java)
|
||||
}
|
||||
}
|
||||
|
||||
// =========================================================================
|
||||
// Section B: FeePaidCurrency.Token (gasless-token) paths
|
||||
// =========================================================================
|
||||
|
||||
@Nested
|
||||
inner class `FeePaidCurrency Token paths` {
|
||||
|
||||
/**
|
||||
* FeePaidCurrency.Token with sufficient token balance → Sufficient.
|
||||
* The from-token is a Token on ETH; fee is paid from a different gasless token
|
||||
* whose balance (5.0) comfortably exceeds the fee (0.001).
|
||||
*/
|
||||
@Test
|
||||
fun `applySwapFee — FeePaidCurrency Token — sufficient gasless-token balance returns Sufficient`() =
|
||||
runTest {
|
||||
val gaslessTokenId = mockk<CryptoCurrency.ID>(relaxed = true)
|
||||
val gaslessToken = mockk<CryptoCurrency.Token>(relaxed = true) {
|
||||
every { id } returns gaslessTokenId
|
||||
}
|
||||
val gaslessTokenStatus = mockk<CryptoCurrencyStatus>(relaxed = true) {
|
||||
every { currency } returns gaslessToken
|
||||
every { value.amount } returns BigDecimal("5.0")
|
||||
}
|
||||
|
||||
// FeePaidCurrency.Token with balance=5.0 > fee=0.001 → Enough
|
||||
coEvery { currenciesRepository.getFeePaidCurrency(any(), any()) } returns FeePaidCurrency.Token(
|
||||
tokenId = gaslessTokenId,
|
||||
name = "GasToken",
|
||||
symbol = "GAS",
|
||||
contractAddress = "0xGasTokenAddress",
|
||||
balance = BigDecimal("5.0"),
|
||||
)
|
||||
|
||||
val fromId = mockk<CryptoCurrency.ID>(relaxed = true)
|
||||
val state = buildQuotesLoadedStateWithTokenFrom(
|
||||
providerType = ExchangeProviderType.DEX,
|
||||
fromAmount = SwapAmount(BigDecimal("1.0"), 18),
|
||||
fromBalance = BigDecimal("10.0"),
|
||||
fromTokenId = fromId,
|
||||
)
|
||||
// selectedFeeToken is the gasless token (different from fromToken)
|
||||
val fee = buildSwapFeeWithExplicitToken(
|
||||
feeValue = BigDecimal("0.001"),
|
||||
tokenStatus = gaslessTokenStatus,
|
||||
tokenId = gaslessTokenId,
|
||||
)
|
||||
|
||||
val result = sut.applySwapFee(state, fee, lastReducedBalanceBy)
|
||||
|
||||
assertThat(result.preparedSwapConfigState.balanceStatus)
|
||||
.isInstanceOf(SwapBalanceStatus.Sufficient::class.java)
|
||||
}
|
||||
|
||||
/**
|
||||
* FeePaidCurrency.Token with insufficient token balance → InsufficientFee.
|
||||
* The gasless token balance (0.0005) is below the fee (0.001).
|
||||
*/
|
||||
@Test
|
||||
fun `applySwapFee — FeePaidCurrency Token — insufficient gasless-token balance returns InsufficientFee`() =
|
||||
runTest {
|
||||
val gaslessTokenId = mockk<CryptoCurrency.ID>(relaxed = true)
|
||||
val gaslessToken = mockk<CryptoCurrency.Token>(relaxed = true) {
|
||||
every { id } returns gaslessTokenId
|
||||
every { name } returns "GasToken"
|
||||
every { symbol } returns "GAS"
|
||||
}
|
||||
val gaslessTokenStatus = mockk<CryptoCurrencyStatus>(relaxed = true) {
|
||||
every { currency } returns gaslessToken
|
||||
every { value.amount } returns BigDecimal("0.0005")
|
||||
}
|
||||
|
||||
// FeePaidCurrency.Token with balance=0.0005 < fee=0.001 → NotEnough
|
||||
coEvery { currenciesRepository.getFeePaidCurrency(any(), any()) } returns FeePaidCurrency.Token(
|
||||
tokenId = gaslessTokenId,
|
||||
name = "GasToken",
|
||||
symbol = "GAS",
|
||||
contractAddress = "0xGasTokenAddress",
|
||||
balance = BigDecimal("0.0005"),
|
||||
)
|
||||
|
||||
val fromId = mockk<CryptoCurrency.ID>(relaxed = true)
|
||||
val state = buildQuotesLoadedStateWithTokenFrom(
|
||||
providerType = ExchangeProviderType.DEX,
|
||||
fromAmount = SwapAmount(BigDecimal("1.0"), 18),
|
||||
fromBalance = BigDecimal("10.0"),
|
||||
fromTokenId = fromId,
|
||||
)
|
||||
val fee = buildSwapFeeWithExplicitToken(
|
||||
feeValue = BigDecimal("0.001"),
|
||||
tokenStatus = gaslessTokenStatus,
|
||||
tokenId = gaslessTokenId,
|
||||
)
|
||||
|
||||
val result = sut.applySwapFee(state, fee, lastReducedBalanceBy)
|
||||
|
||||
assertThat(result.preparedSwapConfigState.balanceStatus)
|
||||
.isInstanceOf(SwapBalanceStatus.InsufficientFee::class.java)
|
||||
}
|
||||
|
||||
/**
|
||||
* FeePaidCurrency.Token — verifies the fee currency name/symbol propagate into
|
||||
* the InsufficientFee status so the UI can show "Not enough GAS for fee".
|
||||
*/
|
||||
@Test
|
||||
fun `applySwapFee — FeePaidCurrency Token — InsufficientFee carries token name and symbol`() = runTest {
|
||||
val gaslessTokenId = mockk<CryptoCurrency.ID>(relaxed = true)
|
||||
val gaslessToken = mockk<CryptoCurrency.Token>(relaxed = true) {
|
||||
every { id } returns gaslessTokenId
|
||||
every { name } returns "GasToken"
|
||||
every { symbol } returns "GAS"
|
||||
}
|
||||
val gaslessTokenStatus = mockk<CryptoCurrencyStatus>(relaxed = true) {
|
||||
every { currency } returns gaslessToken
|
||||
every { value.amount } returns BigDecimal("0.0005")
|
||||
}
|
||||
|
||||
coEvery { currenciesRepository.getFeePaidCurrency(any(), any()) } returns FeePaidCurrency.Token(
|
||||
tokenId = gaslessTokenId,
|
||||
name = "GasToken",
|
||||
symbol = "GAS",
|
||||
contractAddress = "0xGasTokenAddress",
|
||||
balance = BigDecimal("0.0005"),
|
||||
)
|
||||
|
||||
val fromId = mockk<CryptoCurrency.ID>(relaxed = true)
|
||||
val state = buildQuotesLoadedStateWithTokenFrom(
|
||||
providerType = ExchangeProviderType.CEX,
|
||||
fromAmount = SwapAmount(BigDecimal("1.0"), 18),
|
||||
fromBalance = BigDecimal("10.0"),
|
||||
fromTokenId = fromId,
|
||||
)
|
||||
val fee = buildSwapFeeWithExplicitToken(
|
||||
feeValue = BigDecimal("0.001"),
|
||||
tokenStatus = gaslessTokenStatus,
|
||||
tokenId = gaslessTokenId,
|
||||
)
|
||||
|
||||
val result = sut.applySwapFee(state, fee, lastReducedBalanceBy)
|
||||
|
||||
val status = result.preparedSwapConfigState.balanceStatus
|
||||
assertThat(status).isInstanceOf(SwapBalanceStatus.InsufficientFee::class.java)
|
||||
val insufficientFee = status as SwapBalanceStatus.InsufficientFee
|
||||
assertThat(insufficientFee.feeCurrencySymbol).isEqualTo("GAS")
|
||||
assertThat(insufficientFee.feeCurrencyName).isEqualTo("GasToken")
|
||||
}
|
||||
}
|
||||
|
||||
// =========================================================================
|
||||
// Section C: FeePaidCurrency.SameCurrency paths
|
||||
// =========================================================================
|
||||
|
||||
@Nested
|
||||
inner class `FeePaidCurrency SameCurrency paths` {
|
||||
|
||||
/**
|
||||
* FeePaidCurrency.SameCurrency on CEX: fromToken is a Token, fee is paid in the same
|
||||
* token, balance comfortably covers amount + fee → Sufficient.
|
||||
* (This is the Cardano-style path where the fee currency == the send currency.)
|
||||
*/
|
||||
@Test
|
||||
fun `applySwapFee CEX — FeePaidCurrency SameCurrency — sufficient balance returns Sufficient`() = runTest {
|
||||
coEvery { currenciesRepository.getFeePaidCurrency(any(), any()) } returns FeePaidCurrency.SameCurrency
|
||||
|
||||
val state = buildQuotesLoadedState(
|
||||
providerType = ExchangeProviderType.CEX,
|
||||
fromAmount = SwapAmount(BigDecimal("1.0"), 18),
|
||||
isCoin = false,
|
||||
fromBalance = BigDecimal("10.0"),
|
||||
)
|
||||
// Fee is low enough: balance(10) - amount(1) = 9 > fee(0.001)
|
||||
val fee = buildSwapFeeWithCoinToken(feeValue = BigDecimal("0.001"))
|
||||
|
||||
val result = sut.applySwapFee(state, fee, lastReducedBalanceBy)
|
||||
|
||||
assertThat(result.preparedSwapConfigState.balanceStatus)
|
||||
.isInstanceOf(SwapBalanceStatus.Sufficient::class.java)
|
||||
}
|
||||
|
||||
/**
|
||||
* FeePaidCurrency.SameCurrency on DEX: balance - amount just covers the fee → Sufficient.
|
||||
* (DEX doesn't invoke getIncludeFeeInAmountInternal so it falls through to getFeeBalanceState.)
|
||||
*/
|
||||
@Test
|
||||
fun `applySwapFee DEX — FeePaidCurrency SameCurrency — balance minus amount covers fee returns Sufficient`() =
|
||||
runTest {
|
||||
coEvery { currenciesRepository.getFeePaidCurrency(any(), any()) } returns FeePaidCurrency.SameCurrency
|
||||
|
||||
// balance=10, amount=1, fee=0.5 → balance-amount=9 > fee=0.5 → Sufficient
|
||||
val state = buildQuotesLoadedState(
|
||||
providerType = ExchangeProviderType.DEX,
|
||||
fromAmount = SwapAmount(BigDecimal("1.0"), 18),
|
||||
isCoin = false,
|
||||
fromBalance = BigDecimal("10.0"),
|
||||
)
|
||||
val fee = buildSwapFeeWithCoinToken(feeValue = BigDecimal("0.5"))
|
||||
|
||||
val result = sut.applySwapFee(state, fee, lastReducedBalanceBy)
|
||||
|
||||
assertThat(result.preparedSwapConfigState.balanceStatus)
|
||||
.isInstanceOf(SwapBalanceStatus.Sufficient::class.java)
|
||||
}
|
||||
|
||||
/**
|
||||
* FeePaidCurrency.SameCurrency: balance - amount is less than fee → InsufficientFee.
|
||||
*/
|
||||
@Test
|
||||
fun `applySwapFee — FeePaidCurrency SameCurrency — balance minus amount below fee returns InsufficientFee`() =
|
||||
runTest {
|
||||
coEvery { currenciesRepository.getFeePaidCurrency(any(), any()) } returns FeePaidCurrency.SameCurrency
|
||||
|
||||
// balance=1.0, amount=1.0, fee=0.001 → balance-amount=0 ≤ fee → NotEnough
|
||||
val state = buildQuotesLoadedState(
|
||||
providerType = ExchangeProviderType.DEX,
|
||||
fromAmount = SwapAmount(BigDecimal("1.0"), 18),
|
||||
isCoin = false,
|
||||
fromBalance = BigDecimal("1.0"),
|
||||
)
|
||||
val fee = buildSwapFeeWithCoinToken(feeValue = BigDecimal("0.001"))
|
||||
|
||||
val result = sut.applySwapFee(state, fee, lastReducedBalanceBy)
|
||||
|
||||
assertThat(result.preparedSwapConfigState.balanceStatus)
|
||||
.isInstanceOf(SwapBalanceStatus.InsufficientFee::class.java)
|
||||
}
|
||||
}
|
||||
|
||||
// =========================================================================
|
||||
// Section D: FeePaidCurrency.FeeResource paths
|
||||
// =========================================================================
|
||||
|
||||
@Nested
|
||||
inner class `FeePaidCurrency FeeResource paths` {
|
||||
|
||||
/**
|
||||
* FeeResource, isFeeResourceEnough = true → Sufficient (happy path — already tested
|
||||
* in SwapInteractorImplApplySwapFeeTest but verified here for clarity).
|
||||
*/
|
||||
@Test
|
||||
fun `applySwapFee — FeeResource — isFeeResourceEnough true returns Sufficient`() = runTest {
|
||||
coEvery { currenciesRepository.getFeePaidCurrency(any(), any()) } returns
|
||||
FeePaidCurrency.FeeResource(currency = "MANA")
|
||||
coEvery { currencyChecksRepository.checkIfFeeResourceEnough(any(), any(), any()) } returns true
|
||||
|
||||
val state = buildQuotesLoadedState(
|
||||
providerType = ExchangeProviderType.DEX,
|
||||
fromAmount = SwapAmount(BigDecimal("1.0"), 18),
|
||||
isCoin = true,
|
||||
fromBalance = BigDecimal("10.0"),
|
||||
)
|
||||
val fee = buildSwapFeeWithCoinToken(feeValue = BigDecimal("0.001"))
|
||||
|
||||
val result = sut.applySwapFee(state, fee, lastReducedBalanceBy)
|
||||
|
||||
assertThat(result.preparedSwapConfigState.balanceStatus)
|
||||
.isInstanceOf(SwapBalanceStatus.Sufficient::class.java)
|
||||
}
|
||||
|
||||
/**
|
||||
* FeeResource, isFeeResourceEnough = false → InsufficientFee.
|
||||
* This is the MISSING unhappy path that was requested in the audit.
|
||||
*/
|
||||
@Test
|
||||
fun `applySwapFee — FeeResource — isFeeResourceEnough false returns InsufficientFee`() = runTest {
|
||||
coEvery { currenciesRepository.getFeePaidCurrency(any(), any()) } returns
|
||||
FeePaidCurrency.FeeResource(currency = "MANA")
|
||||
coEvery { currencyChecksRepository.checkIfFeeResourceEnough(any(), any(), any()) } returns false
|
||||
|
||||
val state = buildQuotesLoadedState(
|
||||
providerType = ExchangeProviderType.DEX,
|
||||
fromAmount = SwapAmount(BigDecimal("1.0"), 18),
|
||||
isCoin = true,
|
||||
fromBalance = BigDecimal("10.0"),
|
||||
)
|
||||
val fee = buildSwapFeeWithCoinToken(feeValue = BigDecimal("0.001"))
|
||||
|
||||
val result = sut.applySwapFee(state, fee, lastReducedBalanceBy)
|
||||
|
||||
assertThat(result.preparedSwapConfigState.balanceStatus)
|
||||
.isInstanceOf(SwapBalanceStatus.InsufficientFee::class.java)
|
||||
}
|
||||
|
||||
/**
|
||||
* FeeResource on CEX: isFeeResourceEnough = false → InsufficientFee even for CEX,
|
||||
* because CEX's FeeAdjustedAmount path is only taken for native-coin fee deduction,
|
||||
* not for fee resources.
|
||||
*/
|
||||
@Test
|
||||
fun `applySwapFee CEX — FeeResource — isFeeResourceEnough false returns InsufficientFee`() = runTest {
|
||||
coEvery { currenciesRepository.getFeePaidCurrency(any(), any()) } returns
|
||||
FeePaidCurrency.FeeResource(currency = "MANA")
|
||||
coEvery { currencyChecksRepository.checkIfFeeResourceEnough(any(), any(), any()) } returns false
|
||||
|
||||
val state = buildQuotesLoadedState(
|
||||
providerType = ExchangeProviderType.CEX,
|
||||
fromAmount = SwapAmount(BigDecimal("1.0"), 18),
|
||||
isCoin = true,
|
||||
fromBalance = BigDecimal("10.0"),
|
||||
)
|
||||
val fee = buildSwapFeeWithCoinToken(feeValue = BigDecimal("0.001"))
|
||||
|
||||
val result = sut.applySwapFee(state, fee, lastReducedBalanceBy)
|
||||
|
||||
assertThat(result.preparedSwapConfigState.balanceStatus)
|
||||
.isInstanceOf(SwapBalanceStatus.InsufficientFee::class.java)
|
||||
}
|
||||
}
|
||||
|
||||
// =========================================================================
|
||||
// Section E: FeePaidCurrency.Coin — from-token is Token (fee paid separately)
|
||||
// =========================================================================
|
||||
|
||||
@Nested
|
||||
inner class `FeePaidCurrency Coin - from is Token` {
|
||||
|
||||
/**
|
||||
* From-token is an ERC-20 Token, FeePaidCurrency.Coin (ETH pays the gas).
|
||||
* Native balance comfortably covers the fee → Sufficient.
|
||||
* No amount+fee concern because the fee currency (ETH) != from-token (USDC).
|
||||
*/
|
||||
@Test
|
||||
fun `applySwapFee DEX — Coin fee — from is Token — native balance covers fee returns Sufficient`() =
|
||||
runTest {
|
||||
coEvery { currenciesRepository.getFeePaidCurrency(any(), any()) } returns FeePaidCurrency.Coin
|
||||
coEvery {
|
||||
walletManagersFacade.getNativeTokenBalance(any(), any(), any())
|
||||
} returns BigDecimal("0.5")
|
||||
|
||||
val state = buildQuotesLoadedState(
|
||||
providerType = ExchangeProviderType.DEX,
|
||||
fromAmount = SwapAmount(BigDecimal("100.0"), 6), // 100 USDC
|
||||
isCoin = false,
|
||||
fromBalance = BigDecimal("200.0"),
|
||||
)
|
||||
val fee = buildSwapFeeWithCoinToken(feeValue = BigDecimal("0.001"))
|
||||
|
||||
val result = sut.applySwapFee(state, fee, lastReducedBalanceBy)
|
||||
|
||||
assertThat(result.preparedSwapConfigState.balanceStatus)
|
||||
.isInstanceOf(SwapBalanceStatus.Sufficient::class.java)
|
||||
}
|
||||
|
||||
/**
|
||||
* From-token is an ERC-20 Token, FeePaidCurrency.Coin.
|
||||
* Native balance (0.0001 ETH) is less than fee (0.001 ETH) → InsufficientFee.
|
||||
* The from-token balance (200 USDC) is irrelevant for the fee check.
|
||||
*/
|
||||
@Test
|
||||
fun `applySwapFee DEX — Coin fee — from is Token — native balance below fee returns InsufficientFee`() =
|
||||
runTest {
|
||||
coEvery { currenciesRepository.getFeePaidCurrency(any(), any()) } returns FeePaidCurrency.Coin
|
||||
coEvery {
|
||||
walletManagersFacade.getNativeTokenBalance(any(), any(), any())
|
||||
} returns BigDecimal("0.0001")
|
||||
|
||||
val state = buildQuotesLoadedState(
|
||||
providerType = ExchangeProviderType.DEX,
|
||||
fromAmount = SwapAmount(BigDecimal("100.0"), 6),
|
||||
isCoin = false,
|
||||
fromBalance = BigDecimal("200.0"),
|
||||
)
|
||||
val fee = buildSwapFeeWithCoinToken(feeValue = BigDecimal("0.001"))
|
||||
|
||||
val result = sut.applySwapFee(state, fee, lastReducedBalanceBy)
|
||||
|
||||
assertThat(result.preparedSwapConfigState.balanceStatus)
|
||||
.isInstanceOf(SwapBalanceStatus.InsufficientFee::class.java)
|
||||
}
|
||||
|
||||
/**
|
||||
* CEX + from is Token + FeePaidCurrency.Coin.
|
||||
* Amount (100) ≤ token balance (200). Native balance (0.0001) < fee (0.001).
|
||||
*
|
||||
* For CEX the getIncludeFeeInAmountInternal path runs. Because feePaidCurrency is NOT
|
||||
* a same-currency-token (fromToken != feeToken), it falls to getIncludeFeeInAmountForNative
|
||||
* which detects fromCurrency is CryptoCurrency.Token, then checks nativeBalance >= fee.
|
||||
* 0.0001 < 0.001 → BalanceNotEnough → falls through to getFeeBalanceState → InsufficientFee.
|
||||
*
|
||||
* Note: CEX does NOT return FeeAdjustedAmount when from-token is a Token because
|
||||
* feeAdjustedAmount only applies to the native-coin-from path in getIncludeFeeAmountForCoinFee.
|
||||
*/
|
||||
@Test
|
||||
fun `applySwapFee CEX — Coin fee — from is Token — native balance below fee returns InsufficientFee`() =
|
||||
runTest {
|
||||
coEvery { currenciesRepository.getFeePaidCurrency(any(), any()) } returns FeePaidCurrency.Coin
|
||||
coEvery {
|
||||
walletManagersFacade.getNativeTokenBalance(any(), any(), any())
|
||||
} returns BigDecimal("0.0001")
|
||||
|
||||
val state = buildQuotesLoadedState(
|
||||
providerType = ExchangeProviderType.CEX,
|
||||
fromAmount = SwapAmount(BigDecimal("100.0"), 6),
|
||||
isCoin = false,
|
||||
fromBalance = BigDecimal("200.0"),
|
||||
)
|
||||
val fee = buildSwapFeeWithCoinToken(feeValue = BigDecimal("0.001"))
|
||||
|
||||
val result = sut.applySwapFee(state, fee, lastReducedBalanceBy)
|
||||
|
||||
assertThat(result.preparedSwapConfigState.balanceStatus)
|
||||
.isInstanceOf(SwapBalanceStatus.InsufficientFee::class.java)
|
||||
}
|
||||
}
|
||||
|
||||
// =========================================================================
|
||||
// Section F: Amount-alone insufficient (InsufficientAmount)
|
||||
// =========================================================================
|
||||
|
||||
@Nested
|
||||
inner class `InsufficientAmount paths` {
|
||||
|
||||
/**
|
||||
* DEX + fromToken is Coin + amount > balance → InsufficientAmount regardless of fee.
|
||||
* isBalanceEnough() checks amount + fee for Coin, so balance < amount alone → InsufficientAmount.
|
||||
*/
|
||||
@Test
|
||||
fun `applySwapFee DEX — Coin — amount exceeds balance returns InsufficientAmount`() = runTest {
|
||||
coEvery { currenciesRepository.getFeePaidCurrency(any(), any()) } returns FeePaidCurrency.Coin
|
||||
coEvery {
|
||||
walletManagersFacade.getNativeTokenBalance(any(), any(), any())
|
||||
} returns BigDecimal("0.5")
|
||||
|
||||
// Amount = 1.0 but native balance (used for coins) = 0.5
|
||||
val state = buildQuotesLoadedState(
|
||||
providerType = ExchangeProviderType.DEX,
|
||||
fromAmount = SwapAmount(BigDecimal("1.0"), 18),
|
||||
isCoin = true,
|
||||
fromBalance = BigDecimal("0.5"), // status.value.amount used by getTokenBalance
|
||||
)
|
||||
val fee = buildSwapFeeWithCoinToken(feeValue = BigDecimal("0.001"))
|
||||
|
||||
val result = sut.applySwapFee(state, fee, lastReducedBalanceBy)
|
||||
|
||||
assertThat(result.preparedSwapConfigState.balanceStatus)
|
||||
.isInstanceOf(SwapBalanceStatus.InsufficientAmount::class.java)
|
||||
}
|
||||
|
||||
/**
|
||||
* From-token is an ERC-20 Token; amount > token balance → InsufficientAmount.
|
||||
* The native balance is irrelevant for the amount check when from is Token
|
||||
* (FeePaidCurrency.Coin → token balance check only for isBalanceEnough).
|
||||
*/
|
||||
@Test
|
||||
fun `applySwapFee — Token from — amount exceeds token balance returns InsufficientAmount`() = runTest {
|
||||
coEvery { currenciesRepository.getFeePaidCurrency(any(), any()) } returns FeePaidCurrency.Coin
|
||||
coEvery {
|
||||
walletManagersFacade.getNativeTokenBalance(any(), any(), any())
|
||||
} returns BigDecimal("10.0") // plenty of ETH for fee
|
||||
|
||||
// amount = 100 USDC but fromBalance = 50 USDC
|
||||
val state = buildQuotesLoadedState(
|
||||
providerType = ExchangeProviderType.DEX,
|
||||
fromAmount = SwapAmount(BigDecimal("100.0"), 6),
|
||||
isCoin = false,
|
||||
fromBalance = BigDecimal("50.0"),
|
||||
)
|
||||
val fee = buildSwapFeeWithCoinToken(feeValue = BigDecimal("0.001"))
|
||||
|
||||
val result = sut.applySwapFee(state, fee, lastReducedBalanceBy)
|
||||
|
||||
assertThat(result.preparedSwapConfigState.balanceStatus)
|
||||
.isInstanceOf(SwapBalanceStatus.InsufficientAmount::class.java)
|
||||
}
|
||||
}
|
||||
|
||||
// =========================================================================
|
||||
// Section G: FeeAdjustedAmount carries the correct adjusted value
|
||||
// =========================================================================
|
||||
|
||||
@Nested
|
||||
inner class `FeeAdjustedAmount value correctness` {
|
||||
|
||||
/**
|
||||
* CEX + Coin from + amount+fee just barely doesn't fit.
|
||||
* The adjusted amount must be nativeBalance - fee (not zero, not the original amount).
|
||||
*
|
||||
* Scenario:
|
||||
* status.value.amount (fromBalance for isBalanceEnough) = 1.1
|
||||
* walletManagersFacade nativeBalance = 1.0
|
||||
* amount = 0.999, fee = 0.005
|
||||
*
|
||||
* isBalanceEnough: 1.1 >= 0.999 + 0.005 = 1.004 → TRUE
|
||||
* getIncludeFeeAmountForCoinFee:
|
||||
* nativeBalance = 1.0
|
||||
* amount(0.999) ≤ nativeBalance(1.0) ✓
|
||||
* amountWithFee(1.004) > nativeBalance(1.0) ✓
|
||||
* fee(0.005) < amount(0.999) ✓
|
||||
* → Included: adjustedAmount = nativeBalance(1.0) - fee(0.005) = 0.995
|
||||
*/
|
||||
@Test
|
||||
fun `applySwapFee CEX — FeeAdjustedAmount — adjustedAmount equals nativeBalance minus fee`() = runTest {
|
||||
coEvery { currenciesRepository.getFeePaidCurrency(any(), any()) } returns FeePaidCurrency.Coin
|
||||
coEvery {
|
||||
walletManagersFacade.getNativeTokenBalance(any(), any(), any())
|
||||
} returns BigDecimal("1.0")
|
||||
|
||||
val state = buildQuotesLoadedState(
|
||||
providerType = ExchangeProviderType.CEX,
|
||||
fromAmount = SwapAmount(BigDecimal("0.999"), 18),
|
||||
isCoin = true,
|
||||
fromBalance = BigDecimal("1.1"), // larger than amount+fee so isBalanceEnough passes
|
||||
)
|
||||
val fee = buildSwapFeeWithCoinToken(feeValue = BigDecimal("0.005"))
|
||||
|
||||
val result = sut.applySwapFee(state, fee, lastReducedBalanceBy)
|
||||
|
||||
val status = result.preparedSwapConfigState.balanceStatus
|
||||
assertThat(status).isInstanceOf(SwapBalanceStatus.FeeAdjustedAmount::class.java)
|
||||
val adjusted = status as SwapBalanceStatus.FeeAdjustedAmount
|
||||
// adjustedAmount = nativeBalance(1.0) - fee(0.005) = 0.995
|
||||
assertThat(adjusted.adjustedAmount.value).isEquivalentAccordingToCompareTo(BigDecimal("0.995"))
|
||||
}
|
||||
}
|
||||
|
||||
// =========================================================================
|
||||
// Helpers — local builders (scope-specific, private to this test class)
|
||||
// =========================================================================
|
||||
|
||||
private fun buildCurrencyCheck(): CryptoCurrencyCheck = CryptoCurrencyCheck(
|
||||
dustValue = null,
|
||||
reserveAmount = null,
|
||||
minimumSendAmount = null,
|
||||
existentialDeposit = null,
|
||||
utxoAmountLimit = null,
|
||||
isAccountFunded = true,
|
||||
rentWarning = null,
|
||||
isMemoRequired = false,
|
||||
)
|
||||
|
||||
/**
|
||||
* Builds a QuotesLoadedState with the specified provider and a [CryptoCurrency.Coin] from-token
|
||||
* (when [isCoin] = true) or a [CryptoCurrency.Token] from-token (when [isCoin] = false).
|
||||
*/
|
||||
private fun buildQuotesLoadedState(
|
||||
providerType: ExchangeProviderType,
|
||||
fromAmount: SwapAmount,
|
||||
isCoin: Boolean,
|
||||
fromBalance: BigDecimal,
|
||||
): SwapState.QuotesLoadedState {
|
||||
val from = buildSwapCurrencyStatus(
|
||||
networkRawId = ethNetwork,
|
||||
isCoin = isCoin,
|
||||
amount = fromBalance,
|
||||
)
|
||||
val to = buildSwapCurrencyStatus(networkRawId = ethNetwork)
|
||||
return SwapState.QuotesLoadedState(
|
||||
fromTokenInfo = TokenSwapInfo(
|
||||
tokenAmount = fromAmount,
|
||||
swapCurrencyStatus = from,
|
||||
amountFiat = BigDecimal.ZERO,
|
||||
),
|
||||
toTokenInfo = TokenSwapInfo(
|
||||
tokenAmount = SwapAmount(BigDecimal("0.5"), 18),
|
||||
swapCurrencyStatus = to,
|
||||
amountFiat = BigDecimal.ZERO,
|
||||
),
|
||||
priceImpact = PriceImpact.Empty,
|
||||
preparedSwapConfigState = PreparedSwapConfigState(
|
||||
balanceStatus = SwapBalanceStatus.Pending,
|
||||
hasOutgoingTransaction = false,
|
||||
),
|
||||
permissionState = PermissionDataState.Empty,
|
||||
swapDataModel = null,
|
||||
currencyCheck = null,
|
||||
validationResult = null,
|
||||
minAdaValue = null,
|
||||
swapProvider = buildSwapProvider(providerType),
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Like [buildQuotesLoadedState] but creates a Token from-currency with the given [fromTokenId].
|
||||
*/
|
||||
private fun buildQuotesLoadedStateWithTokenFrom(
|
||||
providerType: ExchangeProviderType,
|
||||
fromAmount: SwapAmount,
|
||||
fromBalance: BigDecimal,
|
||||
fromTokenId: CryptoCurrency.ID,
|
||||
): SwapState.QuotesLoadedState {
|
||||
val from = buildSwapCurrencyStatus(
|
||||
networkRawId = ethNetwork,
|
||||
isCoin = false,
|
||||
amount = fromBalance,
|
||||
contractAddress = "0xFromTokenAddress",
|
||||
)
|
||||
// Rewire the id on the currency mock to be the distinct fromTokenId
|
||||
every { from.status.currency.id } returns fromTokenId
|
||||
|
||||
val to = buildSwapCurrencyStatus(networkRawId = ethNetwork)
|
||||
return SwapState.QuotesLoadedState(
|
||||
fromTokenInfo = TokenSwapInfo(
|
||||
tokenAmount = fromAmount,
|
||||
swapCurrencyStatus = from,
|
||||
amountFiat = BigDecimal.ZERO,
|
||||
),
|
||||
toTokenInfo = TokenSwapInfo(
|
||||
tokenAmount = SwapAmount(BigDecimal("0.5"), 18),
|
||||
swapCurrencyStatus = to,
|
||||
amountFiat = BigDecimal.ZERO,
|
||||
),
|
||||
priceImpact = PriceImpact.Empty,
|
||||
preparedSwapConfigState = PreparedSwapConfigState(
|
||||
balanceStatus = SwapBalanceStatus.Pending,
|
||||
hasOutgoingTransaction = false,
|
||||
),
|
||||
permissionState = PermissionDataState.Empty,
|
||||
swapDataModel = null,
|
||||
currencyCheck = null,
|
||||
validationResult = null,
|
||||
minAdaValue = null,
|
||||
swapProvider = buildSwapProvider(providerType),
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Builds a [com.tangem.feature.swap.domain.models.ui.SwapFee] where the [selectedFeeToken]
|
||||
* holds a [CryptoCurrency.Coin] — the normal native-coin fee scenario.
|
||||
*/
|
||||
private fun buildSwapFeeWithCoinToken(
|
||||
feeValue: BigDecimal,
|
||||
otherNativeFee: BigDecimal = BigDecimal.ZERO,
|
||||
): com.tangem.feature.swap.domain.models.ui.SwapFee {
|
||||
val amount = mockk<Amount>(relaxed = true) {
|
||||
every { value } returns feeValue
|
||||
}
|
||||
val fee = mockk<Fee.Common>(relaxed = true) {
|
||||
every { this@mockk.amount } returns amount
|
||||
}
|
||||
val coinCurrency = mockk<CryptoCurrency.Coin>(relaxed = true)
|
||||
val feeTokenStatus = mockk<CryptoCurrencyStatus>(relaxed = true) {
|
||||
every { currency } returns coinCurrency
|
||||
}
|
||||
return com.tangem.feature.swap.domain.models.ui.SwapFee(
|
||||
fee = fee,
|
||||
transactionFeeResult = TransactionFeeResult.Loaded(mockk<TransactionFee.Single>(relaxed = true)),
|
||||
selectedFeeToken = feeTokenStatus,
|
||||
otherNativeFee = otherNativeFee,
|
||||
feeBucket = FeeBucket.MARKET,
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Builds a [com.tangem.feature.swap.domain.models.ui.SwapFee] where [selectedFeeToken]
|
||||
* holds an explicit [CryptoCurrency.Token] — the gasless-token fee scenario.
|
||||
* The [tokenId] must match the one used in the gasless token mock.
|
||||
*/
|
||||
private fun buildSwapFeeWithExplicitToken(
|
||||
feeValue: BigDecimal,
|
||||
tokenStatus: CryptoCurrencyStatus,
|
||||
tokenId: CryptoCurrency.ID,
|
||||
): com.tangem.feature.swap.domain.models.ui.SwapFee {
|
||||
val amount = mockk<Amount>(relaxed = true) {
|
||||
every { value } returns feeValue
|
||||
}
|
||||
val fee = mockk<Fee.Common>(relaxed = true) {
|
||||
every { this@mockk.amount } returns amount
|
||||
}
|
||||
return com.tangem.feature.swap.domain.models.ui.SwapFee(
|
||||
fee = fee,
|
||||
transactionFeeResult = TransactionFeeResult.Loaded(mockk<TransactionFee.Single>(relaxed = true)),
|
||||
selectedFeeToken = tokenStatus,
|
||||
otherNativeFee = BigDecimal.ZERO,
|
||||
feeBucket = FeeBucket.MARKET,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,258 @@
|
|||
package com.tangem.feature.swap.domain
|
||||
|
||||
import arrow.core.right
|
||||
import com.google.common.truth.Truth.assertThat
|
||||
import com.tangem.blockchain.common.Amount
|
||||
import com.tangem.blockchain.common.Blockchain
|
||||
import com.tangem.blockchain.common.transaction.Fee
|
||||
import com.tangem.blockchain.common.transaction.TransactionFee
|
||||
import com.tangem.blockchainsdk.utils.toNetworkId
|
||||
import com.tangem.domain.models.currency.CryptoCurrencyStatus
|
||||
import com.tangem.domain.tokens.model.FeePaidCurrency
|
||||
import com.tangem.domain.tokens.model.warnings.CryptoCurrencyCheck
|
||||
import com.tangem.feature.swap.domain.fee.TransactionFeeResult
|
||||
import com.tangem.feature.swap.domain.models.SwapAmount
|
||||
import com.tangem.feature.swap.domain.models.domain.ExchangeProviderType
|
||||
import com.tangem.feature.swap.domain.models.domain.PreparedSwapConfigState
|
||||
import com.tangem.feature.swap.domain.models.domain.SwapBalanceStatus
|
||||
import com.tangem.feature.swap.domain.models.ui.*
|
||||
import io.mockk.coEvery
|
||||
import io.mockk.every
|
||||
import io.mockk.mockk
|
||||
import kotlinx.coroutines.test.runTest
|
||||
import org.junit.jupiter.api.BeforeEach
|
||||
import org.junit.jupiter.api.Test
|
||||
import org.junit.jupiter.api.TestInstance
|
||||
import java.math.BigDecimal
|
||||
|
||||
/**
|
||||
* Tests for [SwapInteractorImpl.applySwapFee] — [REDACTED_TASK_KEY] Phase 4.
|
||||
*
|
||||
* Verifies:
|
||||
* - The fee value (including bridge `otherNativeFee`) propagates to `feeState`, `isBalanceEnough`,
|
||||
* and `includeFeeInAmount`.
|
||||
* - Each [FeePaidCurrency] branch is recomputed correctly: Coin / SameCurrency / Token / FeeResource.
|
||||
* - Bridge boundary: when native balance is between `fee` and `fee + otherNativeFee`,
|
||||
* `feeState` flips from Enough to NotEnough.
|
||||
* - Idempotency: applying the same SwapFee twice yields equal state.
|
||||
*/
|
||||
@TestInstance(TestInstance.Lifecycle.PER_CLASS)
|
||||
internal class SwapInteractorImplApplySwapFeeTest : SwapInteractorImplTestBase() {
|
||||
|
||||
private val ethNetwork = Blockchain.Ethereum.toNetworkId()
|
||||
private val lastReducedBalanceBy = BigDecimal.ZERO
|
||||
|
||||
@BeforeEach
|
||||
fun setup() {
|
||||
coEvery {
|
||||
getCurrencyCheckUseCase.invoke(
|
||||
userWalletId = any(),
|
||||
currencyStatus = any(),
|
||||
feeCurrencyStatus = any(),
|
||||
amount = any(),
|
||||
fee = any(),
|
||||
feeCurrencyBalanceAfterTransaction = any(),
|
||||
recipientAddress = any(),
|
||||
)
|
||||
} returns buildCurrencyCheck()
|
||||
coEvery {
|
||||
validateTransactionUseCase.invoke(
|
||||
amount = any(),
|
||||
fee = any(),
|
||||
memo = any(),
|
||||
destination = any(),
|
||||
userWalletId = any(),
|
||||
network = any(),
|
||||
)
|
||||
} returns Unit.right()
|
||||
coEvery { walletManagersFacade.getNativeTokenBalance(any(), any(), any()) } returns BigDecimal("10")
|
||||
coEvery { getFeePaidCryptoCurrencyStatusSyncUseCase.invoke(any(), any()) } returns null.right()
|
||||
coEvery { currenciesRepository.createCoinCurrency(any()) } returns buildCoinCurrency()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `applySwapFee recomputes balanceStatus to Sufficient when native balance covers fee`() = runTest {
|
||||
coEvery { currenciesRepository.getFeePaidCurrency(any(), any()) } returns FeePaidCurrency.Coin
|
||||
coEvery { walletManagersFacade.getNativeTokenBalance(any(), any(), any()) } returns BigDecimal("10")
|
||||
|
||||
val state = buildQuotesLoadedState(
|
||||
fromAmount = SwapAmount(BigDecimal("1"), 18),
|
||||
isCoin = true,
|
||||
fromBalance = BigDecimal("10"),
|
||||
)
|
||||
val swapFee = buildSwapFee(feeValue = BigDecimal("0.001"), otherNativeFee = BigDecimal.ZERO)
|
||||
|
||||
val patched = sut.applySwapFee(state, swapFee, lastReducedBalanceBy)
|
||||
|
||||
assertThat(patched.preparedSwapConfigState.balanceStatus).isInstanceOf(SwapBalanceStatus.Sufficient::class.java)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `applySwapFee recomputes balanceStatus to InsufficientFee when native balance below fee`() = runTest {
|
||||
coEvery { currenciesRepository.getFeePaidCurrency(any(), any()) } returns FeePaidCurrency.Coin
|
||||
coEvery { walletManagersFacade.getNativeTokenBalance(any(), any(), any()) } returns BigDecimal("0.0001")
|
||||
|
||||
val state = buildQuotesLoadedState(
|
||||
fromAmount = SwapAmount(BigDecimal("1"), 18),
|
||||
isCoin = false,
|
||||
fromBalance = BigDecimal("10"),
|
||||
)
|
||||
val swapFee = buildSwapFee(feeValue = BigDecimal("0.01"))
|
||||
|
||||
val patched = sut.applySwapFee(state, swapFee, lastReducedBalanceBy)
|
||||
|
||||
assertThat(patched.preparedSwapConfigState.balanceStatus)
|
||||
.isInstanceOf(SwapBalanceStatus.InsufficientFee::class.java)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `applySwapFee — bridge otherNativeFee — boundary flips balanceStatus to InsufficientFee`() = runTest {
|
||||
// From-token is a Token, native fee is small enough alone but combined with otherNativeFee exceeds balance.
|
||||
coEvery { currenciesRepository.getFeePaidCurrency(any(), any()) } returns FeePaidCurrency.Coin
|
||||
coEvery { walletManagersFacade.getNativeTokenBalance(any(), any(), any()) } returns BigDecimal("0.0015")
|
||||
|
||||
val state = buildQuotesLoadedState(
|
||||
fromAmount = SwapAmount(BigDecimal("1"), 18),
|
||||
isCoin = false,
|
||||
fromBalance = BigDecimal("10"),
|
||||
)
|
||||
// fee=0.001, otherNativeFee=0.001 => feeToCheck=0.002 > 0.0015 nativeBalance → InsufficientFee
|
||||
val swapFee = buildSwapFee(
|
||||
feeValue = BigDecimal("0.001"),
|
||||
otherNativeFee = BigDecimal("0.001"),
|
||||
)
|
||||
|
||||
val patched = sut.applySwapFee(state, swapFee, lastReducedBalanceBy)
|
||||
|
||||
assertThat(patched.preparedSwapConfigState.balanceStatus)
|
||||
.isInstanceOf(SwapBalanceStatus.InsufficientFee::class.java)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `applySwapFee — bridge otherNativeFee — Sufficient when balance covers combined fee`() = runTest {
|
||||
coEvery { currenciesRepository.getFeePaidCurrency(any(), any()) } returns FeePaidCurrency.Coin
|
||||
coEvery { walletManagersFacade.getNativeTokenBalance(any(), any(), any()) } returns BigDecimal("0.005")
|
||||
|
||||
val state = buildQuotesLoadedState(
|
||||
fromAmount = SwapAmount(BigDecimal("1"), 18),
|
||||
isCoin = false,
|
||||
fromBalance = BigDecimal("10"),
|
||||
)
|
||||
// fee=0.001, otherNativeFee=0.001 => feeToCheck=0.002 <= 0.005 nativeBalance → Sufficient
|
||||
val swapFee = buildSwapFee(
|
||||
feeValue = BigDecimal("0.001"),
|
||||
otherNativeFee = BigDecimal("0.001"),
|
||||
)
|
||||
|
||||
val patched = sut.applySwapFee(state, swapFee, lastReducedBalanceBy)
|
||||
|
||||
assertThat(patched.preparedSwapConfigState.balanceStatus).isInstanceOf(SwapBalanceStatus.Sufficient::class.java)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `applySwapFee — FeeResource branch flips to Sufficient on isFeeResourceEnough`() = runTest {
|
||||
coEvery {
|
||||
currenciesRepository.getFeePaidCurrency(any(), any())
|
||||
} returns FeePaidCurrency.FeeResource(currency = "FEE")
|
||||
coEvery { currencyChecksRepository.checkIfFeeResourceEnough(any(), any(), any()) } returns true
|
||||
|
||||
val state = buildQuotesLoadedState(
|
||||
fromAmount = SwapAmount(BigDecimal("1"), 18),
|
||||
isCoin = true,
|
||||
fromBalance = BigDecimal("10"),
|
||||
)
|
||||
val swapFee = buildSwapFee(feeValue = BigDecimal("0.001"))
|
||||
|
||||
val patched = sut.applySwapFee(state, swapFee, lastReducedBalanceBy)
|
||||
|
||||
// Stubbed isFeeResourceEnough = true => Sufficient
|
||||
assertThat(patched.preparedSwapConfigState.balanceStatus).isInstanceOf(SwapBalanceStatus.Sufficient::class.java)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `applySwapFee is idempotent — applying twice yields equal state`() = runTest {
|
||||
coEvery { currenciesRepository.getFeePaidCurrency(any(), any()) } returns FeePaidCurrency.Coin
|
||||
coEvery { walletManagersFacade.getNativeTokenBalance(any(), any(), any()) } returns BigDecimal("10")
|
||||
|
||||
val state = buildQuotesLoadedState(
|
||||
fromAmount = SwapAmount(BigDecimal("1"), 18),
|
||||
isCoin = true,
|
||||
fromBalance = BigDecimal("10"),
|
||||
)
|
||||
val swapFee = buildSwapFee(feeValue = BigDecimal("0.001"))
|
||||
|
||||
val first = sut.applySwapFee(state, swapFee, lastReducedBalanceBy)
|
||||
val second = sut.applySwapFee(first, swapFee, lastReducedBalanceBy)
|
||||
|
||||
assertThat(first.preparedSwapConfigState).isEqualTo(second.preparedSwapConfigState)
|
||||
}
|
||||
|
||||
private fun buildCurrencyCheck(): CryptoCurrencyCheck = CryptoCurrencyCheck(
|
||||
dustValue = null,
|
||||
reserveAmount = null,
|
||||
minimumSendAmount = null,
|
||||
existentialDeposit = null,
|
||||
utxoAmountLimit = null,
|
||||
isAccountFunded = true,
|
||||
rentWarning = null,
|
||||
isMemoRequired = false,
|
||||
)
|
||||
|
||||
private fun buildQuotesLoadedState(
|
||||
fromAmount: SwapAmount,
|
||||
isCoin: Boolean,
|
||||
fromBalance: BigDecimal,
|
||||
): SwapState.QuotesLoadedState {
|
||||
val from = buildSwapCurrencyStatus(
|
||||
networkRawId = ethNetwork,
|
||||
isCoin = isCoin,
|
||||
amount = fromBalance,
|
||||
)
|
||||
val to = buildSwapCurrencyStatus(networkRawId = ethNetwork)
|
||||
return SwapState.QuotesLoadedState(
|
||||
fromTokenInfo = TokenSwapInfo(
|
||||
tokenAmount = fromAmount,
|
||||
swapCurrencyStatus = from,
|
||||
amountFiat = BigDecimal.ZERO,
|
||||
),
|
||||
toTokenInfo = TokenSwapInfo(
|
||||
tokenAmount = SwapAmount(BigDecimal("0.5"), 18),
|
||||
swapCurrencyStatus = to,
|
||||
amountFiat = BigDecimal.ZERO,
|
||||
),
|
||||
priceImpact = PriceImpact.Empty,
|
||||
preparedSwapConfigState = PreparedSwapConfigState(
|
||||
balanceStatus = SwapBalanceStatus.Pending,
|
||||
hasOutgoingTransaction = false,
|
||||
),
|
||||
permissionState = PermissionDataState.Empty,
|
||||
swapDataModel = null,
|
||||
currencyCheck = null,
|
||||
validationResult = null,
|
||||
minAdaValue = null,
|
||||
swapProvider = buildSwapProvider(ExchangeProviderType.DEX),
|
||||
)
|
||||
}
|
||||
|
||||
private fun buildSwapFee(
|
||||
feeValue: BigDecimal,
|
||||
otherNativeFee: BigDecimal = BigDecimal.ZERO,
|
||||
): SwapFee {
|
||||
val amount = mockk<Amount>(relaxed = true) {
|
||||
every { value } returns feeValue
|
||||
}
|
||||
val fee = mockk<Fee.Common>(relaxed = true) {
|
||||
every { this@mockk.amount } returns amount
|
||||
}
|
||||
val feeTokenStatus = mockk<CryptoCurrencyStatus>(relaxed = true) {
|
||||
every { currency } returns buildCoinCurrency()
|
||||
}
|
||||
return SwapFee(
|
||||
fee = fee,
|
||||
transactionFeeResult = TransactionFeeResult.Loaded(mockk<TransactionFee.Single>(relaxed = true)),
|
||||
selectedFeeToken = feeTokenStatus,
|
||||
otherNativeFee = otherNativeFee,
|
||||
feeBucket = FeeBucket.MARKET,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
|
@ -7,7 +7,6 @@ import com.google.common.truth.Truth.assertThat
|
|||
import com.tangem.blockchain.blockchains.solana.SolanaTransactionHelper
|
||||
import com.tangem.blockchain.common.Blockchain
|
||||
import com.tangem.blockchain.common.TransactionExtras
|
||||
import com.tangem.blockchain.common.transaction.TransactionFee
|
||||
import com.tangem.blockchainsdk.utils.toNetworkId
|
||||
import com.tangem.domain.models.StatusSource
|
||||
import com.tangem.domain.models.currency.CryptoCurrency
|
||||
|
|
@ -96,7 +95,6 @@ internal class SwapInteractorImplFindBestQuoteTest : SwapInteractorImplTestBase(
|
|||
}
|
||||
coEvery { multiQuoteStatusFetcher.invoke(any()) } returns Unit.right()
|
||||
coEvery { getFeePaidCryptoCurrencyStatusSyncUseCase.invoke(any(), any()) } returns null.right()
|
||||
coEvery { multiWalletCryptoCurrenciesSupplier.getSyncOrNull(any()) } returns null
|
||||
coEvery { currenciesRepository.createCoinCurrency(any()) } returns buildCoinCurrency()
|
||||
every { allowPermissionsHandler.isAddressAllowanceInProgress(any()) } returns false
|
||||
coEvery {
|
||||
|
|
@ -109,9 +107,6 @@ internal class SwapInteractorImplFindBestQuoteTest : SwapInteractorImplTestBase(
|
|||
} returns (AllowanceInfo.Enough(allowance = BigDecimal("1000")) as AllowanceInfo).right()
|
||||
every { createTransactionExtrasUseCase.invoke(data = any(), network = any()) } returns
|
||||
mockk<TransactionExtras>(relaxed = true).right()
|
||||
coEvery {
|
||||
getFeeUseCase.invoke(userWallet = any(), network = any(), transactionData = any())
|
||||
} returns mockk<TransactionFee.Single>(relaxed = true).right()
|
||||
}
|
||||
|
||||
@Nested
|
||||
|
|
@ -132,7 +127,7 @@ internal class SwapInteractorImplFindBestQuoteTest : SwapInteractorImplTestBase(
|
|||
providers = listOf(dexProvider, cexProvider),
|
||||
amountToSwap = "0",
|
||||
reduceBalanceBy = BigDecimal.ZERO,
|
||||
txFeeSealedState = buildTxFeeSealedState(),
|
||||
|
||||
)
|
||||
|
||||
// Then
|
||||
|
|
@ -155,7 +150,7 @@ internal class SwapInteractorImplFindBestQuoteTest : SwapInteractorImplTestBase(
|
|||
providers = listOf(provider),
|
||||
amountToSwap = "not-a-number",
|
||||
reduceBalanceBy = BigDecimal.ZERO,
|
||||
txFeeSealedState = buildTxFeeSealedState(),
|
||||
|
||||
)
|
||||
|
||||
// Then
|
||||
|
|
@ -176,7 +171,7 @@ internal class SwapInteractorImplFindBestQuoteTest : SwapInteractorImplTestBase(
|
|||
providers = emptyList(),
|
||||
amountToSwap = "1.0",
|
||||
reduceBalanceBy = BigDecimal.ZERO,
|
||||
txFeeSealedState = buildTxFeeSealedState(),
|
||||
|
||||
)
|
||||
|
||||
// Then
|
||||
|
|
@ -243,7 +238,7 @@ internal class SwapInteractorImplFindBestQuoteTest : SwapInteractorImplTestBase(
|
|||
providers = listOf(dexProvider),
|
||||
amountToSwap = "1.0",
|
||||
reduceBalanceBy = BigDecimal.ZERO,
|
||||
txFeeSealedState = buildTxFeeSealedState(),
|
||||
|
||||
)
|
||||
|
||||
// Then — has a result entry for the DEX provider; type of state is decided by internal logic
|
||||
|
|
@ -271,7 +266,7 @@ internal class SwapInteractorImplFindBestQuoteTest : SwapInteractorImplTestBase(
|
|||
providers = listOf(dexProvider),
|
||||
amountToSwap = "1.0",
|
||||
reduceBalanceBy = BigDecimal.ZERO,
|
||||
txFeeSealedState = buildTxFeeSealedState(),
|
||||
|
||||
)
|
||||
|
||||
// Then
|
||||
|
|
@ -279,54 +274,58 @@ internal class SwapInteractorImplFindBestQuoteTest : SwapInteractorImplTestBase(
|
|||
val state = result[dexProvider]
|
||||
assertThat(state).isInstanceOf(SwapState.SwapError::class.java)
|
||||
val swapError = (state ?: error("state must not be null")) as SwapState.SwapError
|
||||
assertThat(swapError.error).isEqualTo(ExpressDataError.DexActiveSupplyError)
|
||||
assertThat(swapError.error).isEqualTo(ExpressDataError.DexActiveSupplyError())
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `should set isBalanceEnough to false when from-token balance is less than swap amount`() = runTest {
|
||||
// Given — balance is 0.01, swap amount is 1.0 → insufficient
|
||||
val dexProvider = buildSwapProvider(ExchangeProviderType.DEX)
|
||||
val fromStatus = buildSwapCurrencyStatus(
|
||||
networkRawId = ethNetwork,
|
||||
contractAddress = "0",
|
||||
isCoin = true,
|
||||
amount = BigDecimal("0.01"),
|
||||
)
|
||||
val toStatus = buildSwapCurrencyStatus(networkRawId = btcNetwork)
|
||||
val quoteModel = buildQuoteModel(toAmount = BigDecimal("0.5"))
|
||||
|
||||
coEvery {
|
||||
repository.findBestQuote(
|
||||
userWallet = any(),
|
||||
fromContractAddress = any(),
|
||||
fromNetwork = any(),
|
||||
toContractAddress = any(),
|
||||
toNetwork = any(),
|
||||
fromAmount = any(),
|
||||
fromDecimals = any(),
|
||||
toDecimals = any(),
|
||||
providerId = dexProvider.providerId,
|
||||
rateType = any(),
|
||||
fun `should set balanceStatus to InsufficientAmount when from-token balance is less than swap amount`() =
|
||||
runTest {
|
||||
// Given — balance is 0.01, swap amount is 1.0 → insufficient
|
||||
val dexProvider = buildSwapProvider(ExchangeProviderType.DEX)
|
||||
val fromStatus = buildSwapCurrencyStatus(
|
||||
networkRawId = ethNetwork,
|
||||
contractAddress = "0",
|
||||
isCoin = true,
|
||||
amount = BigDecimal("0.01"),
|
||||
)
|
||||
} returns quoteModel.right()
|
||||
val toStatus = buildSwapCurrencyStatus(networkRawId = btcNetwork)
|
||||
val quoteModel = buildQuoteModel(toAmount = BigDecimal("0.5"))
|
||||
|
||||
// When
|
||||
val result = sut.findBestQuote(
|
||||
fromSwapCurrencyStatus = fromStatus,
|
||||
toSwapCurrencyStatus = toStatus,
|
||||
providers = listOf(dexProvider),
|
||||
amountToSwap = "1.0",
|
||||
reduceBalanceBy = BigDecimal.ZERO,
|
||||
txFeeSealedState = buildTxFeeSealedState(),
|
||||
)
|
||||
coEvery {
|
||||
repository.findBestQuote(
|
||||
userWallet = any(),
|
||||
fromContractAddress = any(),
|
||||
fromNetwork = any(),
|
||||
toContractAddress = any(),
|
||||
toNetwork = any(),
|
||||
fromAmount = any(),
|
||||
fromDecimals = any(),
|
||||
toDecimals = any(),
|
||||
providerId = dexProvider.providerId,
|
||||
rateType = any(),
|
||||
)
|
||||
} returns quoteModel.right()
|
||||
|
||||
// Then
|
||||
assertThat(result).hasSize(1)
|
||||
val state = result[dexProvider]
|
||||
assertThat(state).isInstanceOf(SwapState.QuotesLoadedState::class.java)
|
||||
val loaded = state as SwapState.QuotesLoadedState
|
||||
assertThat(loaded.preparedSwapConfigState.isBalanceEnough).isFalse()
|
||||
}
|
||||
// When
|
||||
val result = sut.findBestQuote(
|
||||
fromSwapCurrencyStatus = fromStatus,
|
||||
toSwapCurrencyStatus = toStatus,
|
||||
providers = listOf(dexProvider),
|
||||
amountToSwap = "1.0",
|
||||
reduceBalanceBy = BigDecimal.ZERO,
|
||||
|
||||
)
|
||||
|
||||
// Then
|
||||
assertThat(result).hasSize(1)
|
||||
val state = result[dexProvider]
|
||||
assertThat(state).isInstanceOf(SwapState.QuotesLoadedState::class.java)
|
||||
val loaded = state as SwapState.QuotesLoadedState
|
||||
assertThat(loaded.preparedSwapConfigState.balanceStatus)
|
||||
.isInstanceOf(
|
||||
com.tangem.feature.swap.domain.models.domain.SwapBalanceStatus.InsufficientAmount::class.java,
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `should return non-null state for DEX provider when repository findBestQuote returns error`() = runTest {
|
||||
|
|
@ -353,7 +352,7 @@ internal class SwapInteractorImplFindBestQuoteTest : SwapInteractorImplTestBase(
|
|||
providerId = dexProvider.providerId,
|
||||
rateType = any(),
|
||||
)
|
||||
} returns ExpressDataError.UnknownError.left()
|
||||
} returns ExpressDataError.UnknownError().left()
|
||||
|
||||
// When
|
||||
val result = sut.findBestQuote(
|
||||
|
|
@ -362,7 +361,7 @@ internal class SwapInteractorImplFindBestQuoteTest : SwapInteractorImplTestBase(
|
|||
providers = listOf(dexProvider),
|
||||
amountToSwap = "1.0",
|
||||
reduceBalanceBy = BigDecimal.ZERO,
|
||||
txFeeSealedState = buildTxFeeSealedState(),
|
||||
|
||||
)
|
||||
|
||||
// Then — a SwapState is emitted for the provider (not an EmptyAmountState)
|
||||
|
|
@ -430,7 +429,7 @@ internal class SwapInteractorImplFindBestQuoteTest : SwapInteractorImplTestBase(
|
|||
providers = listOf(dexBridgeProvider),
|
||||
amountToSwap = "1.0",
|
||||
reduceBalanceBy = BigDecimal.ZERO,
|
||||
txFeeSealedState = buildTxFeeSealedState(),
|
||||
|
||||
)
|
||||
|
||||
// Then
|
||||
|
|
@ -499,7 +498,7 @@ internal class SwapInteractorImplFindBestQuoteTest : SwapInteractorImplTestBase(
|
|||
providers = listOf(dexProvider),
|
||||
amountToSwap = "1.0",
|
||||
reduceBalanceBy = BigDecimal.ZERO,
|
||||
txFeeSealedState = buildTxFeeSealedState(),
|
||||
|
||||
)
|
||||
|
||||
// Then
|
||||
|
|
@ -508,85 +507,84 @@ internal class SwapInteractorImplFindBestQuoteTest : SwapInteractorImplTestBase(
|
|||
}
|
||||
|
||||
@Test
|
||||
fun `should return SwapError TooLargeSolanaTransactionError when tx bytes exceed threshold on Cold wallet`() =
|
||||
runTest {
|
||||
// Given — decode returns an oversized array; mock the Solana helper to preserve its size
|
||||
mockkStatic(Base64::class)
|
||||
every { Base64.decode(any<String>(), any()) } returns ByteArray(931)
|
||||
io.mockk.mockkObject(SolanaTransactionHelper)
|
||||
every {
|
||||
SolanaTransactionHelper.removeSignaturesPlaceholders(any())
|
||||
} returns ByteArray(931)
|
||||
fun `Solana size guard no longer fires during findBestQuote — fee owned by selector`() = runTest {
|
||||
// [REDACTED_TASK_KEY] Phase 4: findBestQuote no longer loads fees, so the Solana size guard
|
||||
// (which lives inside DexSwapFeeCalculator) is not reached here. The guard now fires
|
||||
// only when the fee selector calls loadSwapFee. See DexSwapFeeCalculatorTest for the
|
||||
// size-guard assertion; here we only verify findBestQuote completes without surfacing
|
||||
// it as a SwapError.
|
||||
mockkStatic(Base64::class)
|
||||
every { Base64.decode(any<String>(), any()) } returns ByteArray(931)
|
||||
io.mockk.mockkObject(SolanaTransactionHelper)
|
||||
every {
|
||||
SolanaTransactionHelper.removeSignaturesPlaceholders(any())
|
||||
} returns ByteArray(931)
|
||||
|
||||
val dexProvider = buildSwapProvider(ExchangeProviderType.DEX)
|
||||
val coldWallet = mockk<UserWallet.Cold>(relaxed = true)
|
||||
val fromStatus = buildSwapCurrencyStatus(
|
||||
networkRawId = solanaNetwork,
|
||||
isCoin = true,
|
||||
amount = BigDecimal("10"),
|
||||
).let { status ->
|
||||
// replace the relaxed UserWallet mock with a real Cold mock so `is UserWallet.Cold` is true
|
||||
SwapCurrencyStatus(
|
||||
userWallet = coldWallet,
|
||||
status = status.status,
|
||||
account = status.account,
|
||||
)
|
||||
}
|
||||
val toStatus = buildSwapCurrencyStatus(networkRawId = solanaNetwork)
|
||||
val quoteModel = buildQuoteModel()
|
||||
val solanaSwapData = buildSwapDataModelDex(txData = "oversized==")
|
||||
|
||||
coEvery {
|
||||
repository.findBestQuote(
|
||||
userWallet = any(),
|
||||
fromContractAddress = any(),
|
||||
fromNetwork = solanaNetwork,
|
||||
toContractAddress = any(),
|
||||
toNetwork = any(),
|
||||
fromAmount = any(),
|
||||
fromDecimals = any(),
|
||||
toDecimals = any(),
|
||||
providerId = dexProvider.providerId,
|
||||
rateType = any(),
|
||||
)
|
||||
} returns quoteModel.right()
|
||||
|
||||
coEvery {
|
||||
repository.getExchangeData(
|
||||
userWallet = any(),
|
||||
fromContractAddress = any(),
|
||||
fromNetwork = any(),
|
||||
toContractAddress = any(),
|
||||
fromAddress = any(),
|
||||
toNetwork = any(),
|
||||
fromAmount = any(),
|
||||
fromDecimals = any(),
|
||||
toDecimals = any(),
|
||||
providerId = dexProvider.providerId,
|
||||
rateType = any(),
|
||||
toAddress = any(),
|
||||
expressOperationType = any(),
|
||||
refundAddress = any(),
|
||||
)
|
||||
} returns solanaSwapData.right()
|
||||
|
||||
// When
|
||||
val result = sut.findBestQuote(
|
||||
fromSwapCurrencyStatus = fromStatus,
|
||||
toSwapCurrencyStatus = toStatus,
|
||||
providers = listOf(dexProvider),
|
||||
amountToSwap = "1.0",
|
||||
reduceBalanceBy = BigDecimal.ZERO,
|
||||
txFeeSealedState = buildTxFeeSealedState(),
|
||||
val dexProvider = buildSwapProvider(ExchangeProviderType.DEX)
|
||||
val coldWallet = mockk<UserWallet.Cold>(relaxed = true)
|
||||
val fromStatus = buildSwapCurrencyStatus(
|
||||
networkRawId = solanaNetwork,
|
||||
isCoin = true,
|
||||
amount = BigDecimal("10"),
|
||||
).let { status ->
|
||||
SwapCurrencyStatus(
|
||||
userWallet = coldWallet,
|
||||
status = status.status,
|
||||
account = status.account,
|
||||
)
|
||||
|
||||
// Then — oversized Solana tx on Cold wallet produces SwapError with TooLargeSolanaTransactionError
|
||||
assertThat(result).hasSize(1)
|
||||
val state = result[dexProvider]
|
||||
assertThat(state).isInstanceOf(SwapState.SwapError::class.java)
|
||||
val swapError = state as SwapState.SwapError
|
||||
assertThat(swapError.error).isEqualTo(ExpressDataError.TooLargeSolanaTransactionError)
|
||||
}
|
||||
val toStatus = buildSwapCurrencyStatus(networkRawId = solanaNetwork)
|
||||
val quoteModel = buildQuoteModel()
|
||||
val solanaSwapData = buildSwapDataModelDex(txData = "oversized==")
|
||||
|
||||
coEvery {
|
||||
repository.findBestQuote(
|
||||
userWallet = any(),
|
||||
fromContractAddress = any(),
|
||||
fromNetwork = solanaNetwork,
|
||||
toContractAddress = any(),
|
||||
toNetwork = any(),
|
||||
fromAmount = any(),
|
||||
fromDecimals = any(),
|
||||
toDecimals = any(),
|
||||
providerId = dexProvider.providerId,
|
||||
rateType = any(),
|
||||
)
|
||||
} returns quoteModel.right()
|
||||
|
||||
coEvery {
|
||||
repository.getExchangeData(
|
||||
userWallet = any(),
|
||||
fromContractAddress = any(),
|
||||
fromNetwork = any(),
|
||||
toContractAddress = any(),
|
||||
fromAddress = any(),
|
||||
toNetwork = any(),
|
||||
fromAmount = any(),
|
||||
fromDecimals = any(),
|
||||
toDecimals = any(),
|
||||
providerId = dexProvider.providerId,
|
||||
rateType = any(),
|
||||
toAddress = any(),
|
||||
expressOperationType = any(),
|
||||
refundAddress = any(),
|
||||
)
|
||||
} returns solanaSwapData.right()
|
||||
|
||||
// When
|
||||
val result = sut.findBestQuote(
|
||||
fromSwapCurrencyStatus = fromStatus,
|
||||
toSwapCurrencyStatus = toStatus,
|
||||
providers = listOf(dexProvider),
|
||||
amountToSwap = "1.0",
|
||||
reduceBalanceBy = BigDecimal.ZERO,
|
||||
)
|
||||
|
||||
// Then — under Phase 4, findBestQuote returns QuotesLoadedState; size guard is deferred
|
||||
assertThat(result).hasSize(1)
|
||||
val state = result[dexProvider]
|
||||
assertThat(state).isInstanceOf(SwapState.QuotesLoadedState::class.java)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `should produce non-empty state via Solana path when balance insufficient`() = runTest {
|
||||
|
|
@ -622,7 +620,7 @@ internal class SwapInteractorImplFindBestQuoteTest : SwapInteractorImplTestBase(
|
|||
providers = listOf(dexProvider),
|
||||
amountToSwap = "1000.0",
|
||||
reduceBalanceBy = BigDecimal.ZERO,
|
||||
txFeeSealedState = buildTxFeeSealedState(),
|
||||
|
||||
)
|
||||
|
||||
// Then
|
||||
|
|
@ -668,7 +666,7 @@ internal class SwapInteractorImplFindBestQuoteTest : SwapInteractorImplTestBase(
|
|||
providers = listOf(cexProvider),
|
||||
amountToSwap = "1.0",
|
||||
reduceBalanceBy = BigDecimal.ZERO,
|
||||
txFeeSealedState = buildTxFeeSealedState(),
|
||||
|
||||
)
|
||||
|
||||
// Then
|
||||
|
|
@ -711,7 +709,7 @@ internal class SwapInteractorImplFindBestQuoteTest : SwapInteractorImplTestBase(
|
|||
providers = listOf(cexProvider),
|
||||
amountToSwap = "1.0",
|
||||
reduceBalanceBy = BigDecimal.ZERO,
|
||||
txFeeSealedState = buildTxFeeSealedState(),
|
||||
|
||||
)
|
||||
|
||||
// Then
|
||||
|
|
@ -793,7 +791,7 @@ internal class SwapInteractorImplFindBestQuoteTest : SwapInteractorImplTestBase(
|
|||
providers = listOf(dexProvider, cexProvider),
|
||||
amountToSwap = "1.0",
|
||||
reduceBalanceBy = BigDecimal.ZERO,
|
||||
txFeeSealedState = buildTxFeeSealedState(),
|
||||
|
||||
)
|
||||
|
||||
// Then — both providers have an entry
|
||||
|
|
@ -862,7 +860,7 @@ internal class SwapInteractorImplFindBestQuoteTest : SwapInteractorImplTestBase(
|
|||
providers = listOf(dexProvider, cexProvider, dexBridgeProvider),
|
||||
amountToSwap = "1.0",
|
||||
reduceBalanceBy = BigDecimal.ZERO,
|
||||
txFeeSealedState = buildTxFeeSealedState(),
|
||||
|
||||
)
|
||||
|
||||
// Then — all three providers are dispatched and each has an entry
|
||||
|
|
|
|||
|
|
@ -1,86 +0,0 @@
|
|||
package com.tangem.feature.swap.domain
|
||||
|
||||
import com.google.common.truth.Truth.assertThat
|
||||
import com.tangem.blockchain.common.Blockchain
|
||||
import com.tangem.blockchainsdk.utils.toNetworkId
|
||||
import com.tangem.domain.models.currency.CryptoCurrency
|
||||
import com.tangem.domain.models.network.Network
|
||||
import io.mockk.coEvery
|
||||
import io.mockk.coVerify
|
||||
import io.mockk.every
|
||||
import io.mockk.mockk
|
||||
import kotlinx.coroutines.test.runTest
|
||||
import org.junit.jupiter.api.Test
|
||||
import org.junit.jupiter.api.TestInstance
|
||||
|
||||
/**
|
||||
* Tests for [SwapInteractorImpl.getNativeToken].
|
||||
*
|
||||
* Behavior:
|
||||
* - Look up cached portfolio coins for the user wallet via [MultiWalletCryptoCurrenciesSupplier].
|
||||
* - Return the coin matching the target network (by `id` and `derivationPath`).
|
||||
* - If supplier returns null or no match → fall back to [CurrenciesRepository.createCoinCurrency].
|
||||
*/
|
||||
@TestInstance(TestInstance.Lifecycle.PER_CLASS)
|
||||
internal class SwapInteractorImplGetNativeTokenTest : SwapInteractorImplTestBase() {
|
||||
|
||||
private val ethNetwork = Blockchain.Ethereum.toNetworkId()
|
||||
|
||||
@Test
|
||||
fun `should return a Coin from the supplier whose network matches the target`() = runTest {
|
||||
// Given — a single matching coin in the supplier
|
||||
val fromStatus = buildSwapCurrencyStatus(networkRawId = ethNetwork, isCoin = true)
|
||||
val targetNetwork = fromStatus.currency.network
|
||||
|
||||
val matchingCoin = mockk<CryptoCurrency.Coin>(relaxed = true) {
|
||||
every { network } returns targetNetwork
|
||||
}
|
||||
|
||||
coEvery { multiWalletCryptoCurrenciesSupplier.getSyncOrNull(any()) } returns setOf(matchingCoin)
|
||||
|
||||
// When
|
||||
val result = sut.getNativeToken(fromStatus)
|
||||
|
||||
// Then
|
||||
assertThat(result).isSameInstanceAs(matchingCoin)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `should fall back to createCoinCurrency when supplier returns null`() = runTest {
|
||||
// Given
|
||||
val fromStatus = buildSwapCurrencyStatus(networkRawId = ethNetwork, isCoin = true)
|
||||
val createdCoin = buildCoinCurrency(networkRawId = ethNetwork)
|
||||
|
||||
coEvery { multiWalletCryptoCurrenciesSupplier.getSyncOrNull(any()) } returns null
|
||||
coEvery { currenciesRepository.createCoinCurrency(any()) } returns createdCoin
|
||||
|
||||
// When
|
||||
val result = sut.getNativeToken(fromStatus)
|
||||
|
||||
// Then
|
||||
assertThat(result).isSameInstanceAs(createdCoin)
|
||||
coVerify(exactly = 1) { currenciesRepository.createCoinCurrency(any()) }
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `should fall back to createCoinCurrency when no matching coin is in the supplier's list`() = runTest {
|
||||
// Given — all returned coins are for a different network
|
||||
val fromStatus = buildSwapCurrencyStatus(networkRawId = ethNetwork, isCoin = true)
|
||||
val unrelatedCoin = mockk<CryptoCurrency.Coin>(relaxed = true) {
|
||||
every { network } returns mockk<Network>(relaxed = true) {
|
||||
every { id } returns mockk(relaxed = true)
|
||||
every { derivationPath } returns Network.DerivationPath.None
|
||||
}
|
||||
}
|
||||
val createdCoin = buildCoinCurrency(networkRawId = ethNetwork)
|
||||
|
||||
coEvery { multiWalletCryptoCurrenciesSupplier.getSyncOrNull(any()) } returns setOf(unrelatedCoin)
|
||||
coEvery { currenciesRepository.createCoinCurrency(any()) } returns createdCoin
|
||||
|
||||
// When
|
||||
val result = sut.getNativeToken(fromStatus)
|
||||
|
||||
// Then
|
||||
assertThat(result).isSameInstanceAs(createdCoin)
|
||||
}
|
||||
}
|
||||
|
|
@ -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()) }
|
||||
}
|
||||
}
|
||||
|
|
@ -1,441 +0,0 @@
|
|||
package com.tangem.feature.swap.domain
|
||||
|
||||
import arrow.core.left
|
||||
import arrow.core.right
|
||||
import com.google.common.truth.Truth.assertThat
|
||||
import com.tangem.blockchain.common.Blockchain
|
||||
import com.tangem.blockchainsdk.utils.toNetworkId
|
||||
import com.tangem.domain.models.currency.CryptoCurrencyStatus
|
||||
import com.tangem.domain.transaction.error.GetFeeError
|
||||
import com.tangem.domain.transaction.models.TransactionFeeExtended
|
||||
import com.tangem.feature.swap.domain.models.ExpressDataError
|
||||
import com.tangem.feature.swap.domain.models.domain.ExchangeProviderType
|
||||
import io.mockk.coEvery
|
||||
import io.mockk.coVerify
|
||||
import io.mockk.mockk
|
||||
import io.mockk.slot
|
||||
import kotlinx.coroutines.test.runTest
|
||||
import org.junit.jupiter.api.Nested
|
||||
import org.junit.jupiter.api.Test
|
||||
import org.junit.jupiter.api.TestInstance
|
||||
import java.math.BigDecimal
|
||||
|
||||
/**
|
||||
* Tests for [SwapInteractorImpl.loadFeeForSwapTransaction] (both overloads).
|
||||
*
|
||||
* Overload 1 (returns [Either<GetFeeError, TransactionFeeExtended>]):
|
||||
* - DEX / DEX_BRIDGE → always GaslessError.NetworkIsNotSupported
|
||||
* - CEX + zero or unparseable amount → UnknownError
|
||||
* - CEX + selectedFeeToken != null → delegates to [estimateFeeForTokenUseCase]
|
||||
* - CEX + selectedFeeToken == null → delegates to [estimateFeeForGaslessTxUseCase]
|
||||
*
|
||||
* Overload 2 (returns [Either<GetFeeError, TransactionFee>]):
|
||||
* - DEX / DEX_BRIDGE + zero amount → UnknownError
|
||||
* - DEX / DEX_BRIDGE + getExchangeData error → UnknownError
|
||||
* - CEX + zero amount → UnknownError
|
||||
* - CEX + non-zero amount → delegates to [estimateFeeUseCase]
|
||||
*/
|
||||
@TestInstance(TestInstance.Lifecycle.PER_CLASS)
|
||||
internal class SwapInteractorImplLoadFeeTest : SwapInteractorImplTestBase() {
|
||||
|
||||
private val ethNetwork = Blockchain.Ethereum.toNetworkId()
|
||||
private val btcNetwork = Blockchain.Bitcoin.toNetworkId()
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// Overload 1
|
||||
// -------------------------------------------------------------------------
|
||||
|
||||
@Nested
|
||||
inner class `overload 1 — CEX and token fee paths` {
|
||||
|
||||
@Test
|
||||
fun `should return Left GaslessError for DEX provider`() = runTest {
|
||||
// Given
|
||||
val dexProvider = buildSwapProvider(ExchangeProviderType.DEX)
|
||||
val fromStatus = buildSwapCurrencyStatus(networkRawId = ethNetwork)
|
||||
|
||||
// When
|
||||
val result = sut.loadFeeForSwapTransaction(
|
||||
fromSwapCurrencyStatus = fromStatus,
|
||||
amount = "1.0",
|
||||
reduceBalanceBy = BigDecimal.ZERO,
|
||||
provider = dexProvider,
|
||||
selectedFeeToken = null,
|
||||
)
|
||||
|
||||
// Then
|
||||
assertThat(result.isLeft()).isTrue()
|
||||
result.onLeft { error ->
|
||||
assertThat(error).isInstanceOf(GetFeeError.GaslessError.NetworkIsNotSupported::class.java)
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `should return Left GaslessError for DEX_BRIDGE provider`() = runTest {
|
||||
// Given
|
||||
val dexBridgeProvider = buildSwapProvider(ExchangeProviderType.DEX_BRIDGE)
|
||||
val fromStatus = buildSwapCurrencyStatus(networkRawId = ethNetwork)
|
||||
|
||||
// When
|
||||
val result = sut.loadFeeForSwapTransaction(
|
||||
fromSwapCurrencyStatus = fromStatus,
|
||||
amount = "1.0",
|
||||
reduceBalanceBy = BigDecimal.ZERO,
|
||||
provider = dexBridgeProvider,
|
||||
selectedFeeToken = null,
|
||||
)
|
||||
|
||||
// Then
|
||||
assertThat(result.isLeft()).isTrue()
|
||||
result.onLeft { error ->
|
||||
assertThat(error).isInstanceOf(GetFeeError.GaslessError.NetworkIsNotSupported::class.java)
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `should return Left UnknownError for CEX provider when amount is zero`() = runTest {
|
||||
// Given
|
||||
val cexProvider = buildSwapProvider(ExchangeProviderType.CEX)
|
||||
val fromStatus = buildSwapCurrencyStatus(networkRawId = ethNetwork)
|
||||
|
||||
// When
|
||||
val result = sut.loadFeeForSwapTransaction(
|
||||
fromSwapCurrencyStatus = fromStatus,
|
||||
amount = "0",
|
||||
reduceBalanceBy = BigDecimal.ZERO,
|
||||
provider = cexProvider,
|
||||
selectedFeeToken = null,
|
||||
)
|
||||
|
||||
// Then
|
||||
assertThat(result.isLeft()).isTrue()
|
||||
result.onLeft { error ->
|
||||
assertThat(error).isInstanceOf(GetFeeError.UnknownError::class.java)
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `should return Left UnknownError for CEX provider when amount is invalid string`() = runTest {
|
||||
// Given
|
||||
val cexProvider = buildSwapProvider(ExchangeProviderType.CEX)
|
||||
val fromStatus = buildSwapCurrencyStatus(networkRawId = ethNetwork)
|
||||
|
||||
// When
|
||||
val result = sut.loadFeeForSwapTransaction(
|
||||
fromSwapCurrencyStatus = fromStatus,
|
||||
amount = "not-a-decimal",
|
||||
reduceBalanceBy = BigDecimal.ZERO,
|
||||
provider = cexProvider,
|
||||
selectedFeeToken = null,
|
||||
)
|
||||
|
||||
// Then
|
||||
assertThat(result.isLeft()).isTrue()
|
||||
result.onLeft { error ->
|
||||
assertThat(error).isInstanceOf(GetFeeError.UnknownError::class.java)
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `should delegate to estimateFeeForTokenUseCase when CEX provider has non-null selectedFeeToken`() =
|
||||
runTest {
|
||||
// Given
|
||||
val cexProvider = buildSwapProvider(ExchangeProviderType.CEX)
|
||||
val fromStatus = buildSwapCurrencyStatus(networkRawId = ethNetwork)
|
||||
val feeTokenStatus = mockk<CryptoCurrencyStatus>(relaxed = true)
|
||||
val expectedFeeExtended = mockk<TransactionFeeExtended>(relaxed = true)
|
||||
|
||||
coEvery {
|
||||
estimateFeeForTokenUseCase.invoke(
|
||||
userWallet = any(),
|
||||
feeTokenCurrencyStatus = feeTokenStatus,
|
||||
sendingTokenCurrencyStatus = any(),
|
||||
amount = any(),
|
||||
)
|
||||
} returns expectedFeeExtended.right()
|
||||
|
||||
// When
|
||||
val result = sut.loadFeeForSwapTransaction(
|
||||
fromSwapCurrencyStatus = fromStatus,
|
||||
amount = "1.5",
|
||||
reduceBalanceBy = BigDecimal.ZERO,
|
||||
provider = cexProvider,
|
||||
selectedFeeToken = feeTokenStatus,
|
||||
)
|
||||
|
||||
// Then
|
||||
assertThat(result.isRight()).isTrue()
|
||||
coVerify(exactly = 1) {
|
||||
estimateFeeForTokenUseCase.invoke(
|
||||
userWallet = any(),
|
||||
feeTokenCurrencyStatus = feeTokenStatus,
|
||||
sendingTokenCurrencyStatus = any(),
|
||||
amount = BigDecimal("1.5"),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `should pass positive non-NaN amount to estimateFeeForGaslessTxUseCase for CEX with tiny nonzero amount and null selectedFeeToken`() =
|
||||
runTest {
|
||||
// Given — tiny but nonzero amount; null selectedFeeToken routes to estimateFeeForGaslessTxUseCase
|
||||
val cexProvider = buildSwapProvider(ExchangeProviderType.CEX)
|
||||
val fromStatus = buildSwapCurrencyStatus(networkRawId = ethNetwork)
|
||||
val feeExtended = mockk<TransactionFeeExtended>(relaxed = true)
|
||||
val capturedAmount = slot<BigDecimal>()
|
||||
|
||||
coEvery {
|
||||
estimateFeeForGaslessTxUseCase.invoke(
|
||||
amount = capture(capturedAmount),
|
||||
userWallet = any(),
|
||||
sendingTokenCurrencyStatus = any(),
|
||||
)
|
||||
} returns feeExtended.right()
|
||||
|
||||
// When
|
||||
sut.loadFeeForSwapTransaction(
|
||||
fromSwapCurrencyStatus = fromStatus,
|
||||
amount = "0.000001",
|
||||
reduceBalanceBy = BigDecimal.ZERO,
|
||||
provider = cexProvider,
|
||||
selectedFeeToken = null,
|
||||
)
|
||||
|
||||
// Then — captured amount is positive, finite, non-NaN
|
||||
assertThat(capturedAmount.captured).isNotNull()
|
||||
assertThat(capturedAmount.captured.signum()).isGreaterThan(0)
|
||||
assertThat(capturedAmount.captured.toDouble().isNaN()).isFalse()
|
||||
assertThat(capturedAmount.captured.toDouble().isInfinite()).isFalse()
|
||||
// verify estimateFeeForGaslessTxUseCase was called with the exact parsed amount
|
||||
coVerify(exactly = 1) {
|
||||
estimateFeeForGaslessTxUseCase.invoke(
|
||||
amount = BigDecimal("0.000001"),
|
||||
userWallet = any(),
|
||||
sendingTokenCurrencyStatus = any(),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `should delegate to estimateFeeForGaslessTxUseCase when CEX provider has null selectedFeeToken`() =
|
||||
runTest {
|
||||
// Given
|
||||
val cexProvider = buildSwapProvider(ExchangeProviderType.CEX)
|
||||
val fromStatus = buildSwapCurrencyStatus(networkRawId = ethNetwork)
|
||||
val expectedFeeExtended = mockk<TransactionFeeExtended>(relaxed = true)
|
||||
|
||||
coEvery {
|
||||
estimateFeeForGaslessTxUseCase.invoke(
|
||||
amount = any(),
|
||||
userWallet = any(),
|
||||
sendingTokenCurrencyStatus = any(),
|
||||
)
|
||||
} returns expectedFeeExtended.right()
|
||||
|
||||
// When
|
||||
val result = sut.loadFeeForSwapTransaction(
|
||||
fromSwapCurrencyStatus = fromStatus,
|
||||
amount = "2.0",
|
||||
reduceBalanceBy = BigDecimal.ZERO,
|
||||
provider = cexProvider,
|
||||
selectedFeeToken = null,
|
||||
)
|
||||
|
||||
// Then
|
||||
assertThat(result.isRight()).isTrue()
|
||||
coVerify(exactly = 1) {
|
||||
estimateFeeForGaslessTxUseCase.invoke(
|
||||
amount = BigDecimal("2.0"),
|
||||
userWallet = any(),
|
||||
sendingTokenCurrencyStatus = any(),
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// Overload 2
|
||||
// -------------------------------------------------------------------------
|
||||
|
||||
@Nested
|
||||
inner class `overload 2 — DEX and CEX TransactionFee paths` {
|
||||
|
||||
@Test
|
||||
fun `should return Left UnknownError for DEX when amount is zero`() = runTest {
|
||||
// Given
|
||||
val dexProvider = buildSwapProvider(ExchangeProviderType.DEX)
|
||||
val fromStatus = buildSwapCurrencyStatus(networkRawId = ethNetwork)
|
||||
val toStatus = buildSwapCurrencyStatus(networkRawId = btcNetwork)
|
||||
|
||||
// When
|
||||
val result = sut.loadFeeForSwapTransaction(
|
||||
fromSwapCurrencyStatus = fromStatus,
|
||||
toSwapCurrencyStatus = toStatus,
|
||||
amount = "0",
|
||||
reduceBalanceBy = BigDecimal.ZERO,
|
||||
provider = dexProvider,
|
||||
)
|
||||
|
||||
// Then
|
||||
assertThat(result.isLeft()).isTrue()
|
||||
result.onLeft { error ->
|
||||
assertThat(error).isInstanceOf(GetFeeError.UnknownError::class.java)
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `should return Left UnknownError for DEX when getExchangeData returns error`() = runTest {
|
||||
// Given
|
||||
val dexProvider = buildSwapProvider(ExchangeProviderType.DEX)
|
||||
val fromStatus = buildSwapCurrencyStatus(networkRawId = ethNetwork)
|
||||
val toStatus = buildSwapCurrencyStatus(networkRawId = btcNetwork)
|
||||
|
||||
coEvery {
|
||||
repository.getExchangeData(
|
||||
userWallet = any(),
|
||||
fromContractAddress = any(),
|
||||
fromNetwork = any(),
|
||||
toContractAddress = any(),
|
||||
fromAddress = any(),
|
||||
toNetwork = any(),
|
||||
fromAmount = any(),
|
||||
fromDecimals = any(),
|
||||
toDecimals = any(),
|
||||
providerId = any(),
|
||||
rateType = any(),
|
||||
toAddress = any(),
|
||||
expressOperationType = any(),
|
||||
refundAddress = any(),
|
||||
)
|
||||
} returns ExpressDataError.UnknownError.left()
|
||||
|
||||
// When
|
||||
val result = sut.loadFeeForSwapTransaction(
|
||||
fromSwapCurrencyStatus = fromStatus,
|
||||
toSwapCurrencyStatus = toStatus,
|
||||
amount = "1.0",
|
||||
reduceBalanceBy = BigDecimal.ZERO,
|
||||
provider = dexProvider,
|
||||
)
|
||||
|
||||
// Then
|
||||
assertThat(result.isLeft()).isTrue()
|
||||
result.onLeft { error ->
|
||||
assertThat(error).isInstanceOf(GetFeeError.UnknownError::class.java)
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `should not call getExchangeData and return UnknownError for DEX when amount is zero`() = runTest {
|
||||
// Given — zero amount must short-circuit before hitting repository
|
||||
val dexProvider = buildSwapProvider(ExchangeProviderType.DEX)
|
||||
val fromStatus = buildSwapCurrencyStatus(networkRawId = ethNetwork)
|
||||
val toStatus = buildSwapCurrencyStatus(networkRawId = btcNetwork)
|
||||
|
||||
// When
|
||||
val result = sut.loadFeeForSwapTransaction(
|
||||
fromSwapCurrencyStatus = fromStatus,
|
||||
toSwapCurrencyStatus = toStatus,
|
||||
amount = "0",
|
||||
reduceBalanceBy = BigDecimal.ZERO,
|
||||
provider = dexProvider,
|
||||
)
|
||||
|
||||
// Then
|
||||
assertThat(result.isLeft()).isTrue()
|
||||
result.onLeft { error -> assertThat(error).isInstanceOf(GetFeeError.UnknownError::class.java) }
|
||||
coVerify(exactly = 0) {
|
||||
repository.getExchangeData(
|
||||
userWallet = any(),
|
||||
fromContractAddress = any(),
|
||||
fromNetwork = any(),
|
||||
toContractAddress = any(),
|
||||
fromAddress = any(),
|
||||
toNetwork = any(),
|
||||
fromAmount = any(),
|
||||
fromDecimals = any(),
|
||||
toDecimals = any(),
|
||||
providerId = any(),
|
||||
rateType = any(),
|
||||
toAddress = any(),
|
||||
expressOperationType = any(),
|
||||
refundAddress = any(),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `should return Left UnknownError for DEX_BRIDGE when amount is zero`() = runTest {
|
||||
// Given
|
||||
val dexBridgeProvider = buildSwapProvider(ExchangeProviderType.DEX_BRIDGE)
|
||||
val fromStatus = buildSwapCurrencyStatus(networkRawId = ethNetwork)
|
||||
val toStatus = buildSwapCurrencyStatus(networkRawId = btcNetwork)
|
||||
|
||||
// When
|
||||
val result = sut.loadFeeForSwapTransaction(
|
||||
fromSwapCurrencyStatus = fromStatus,
|
||||
toSwapCurrencyStatus = toStatus,
|
||||
amount = "0",
|
||||
reduceBalanceBy = BigDecimal.ZERO,
|
||||
provider = dexBridgeProvider,
|
||||
)
|
||||
|
||||
// Then
|
||||
assertThat(result.isLeft()).isTrue()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `should return Left UnknownError for CEX when amount is zero`() = runTest {
|
||||
// Given
|
||||
val cexProvider = buildSwapProvider(ExchangeProviderType.CEX)
|
||||
val fromStatus = buildSwapCurrencyStatus(networkRawId = ethNetwork)
|
||||
val toStatus = buildSwapCurrencyStatus(networkRawId = btcNetwork)
|
||||
|
||||
// When
|
||||
val result = sut.loadFeeForSwapTransaction(
|
||||
fromSwapCurrencyStatus = fromStatus,
|
||||
toSwapCurrencyStatus = toStatus,
|
||||
amount = "0",
|
||||
reduceBalanceBy = BigDecimal.ZERO,
|
||||
provider = cexProvider,
|
||||
)
|
||||
|
||||
// Then
|
||||
assertThat(result.isLeft()).isTrue()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `should delegate to estimateFeeUseCase for CEX provider with non-zero amount`() = runTest {
|
||||
// Given — return Left to avoid the patchTransactionFeeForSwap branch which requires concrete Fee types
|
||||
val cexProvider = buildSwapProvider(ExchangeProviderType.CEX)
|
||||
val fromStatus = buildSwapCurrencyStatus(networkRawId = ethNetwork)
|
||||
val toStatus = buildSwapCurrencyStatus(networkRawId = btcNetwork)
|
||||
|
||||
coEvery {
|
||||
estimateFeeUseCase.invoke(
|
||||
amount = any(),
|
||||
userWallet = any(),
|
||||
cryptoCurrencyStatus = any(),
|
||||
)
|
||||
} returns GetFeeError.UnknownError.left()
|
||||
|
||||
// When
|
||||
sut.loadFeeForSwapTransaction(
|
||||
fromSwapCurrencyStatus = fromStatus,
|
||||
toSwapCurrencyStatus = toStatus,
|
||||
amount = "1.0",
|
||||
reduceBalanceBy = BigDecimal.ZERO,
|
||||
provider = cexProvider,
|
||||
)
|
||||
|
||||
// Then
|
||||
coVerify(exactly = 1) {
|
||||
estimateFeeUseCase.invoke(
|
||||
amount = BigDecimal("1.0"),
|
||||
userWallet = any(),
|
||||
cryptoCurrencyStatus = any(),
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,602 @@
|
|||
package com.tangem.feature.swap.domain
|
||||
|
||||
import arrow.core.left
|
||||
import arrow.core.right
|
||||
import com.google.common.truth.Truth.assertThat
|
||||
import com.tangem.blockchain.common.Amount
|
||||
import com.tangem.blockchain.common.Blockchain
|
||||
import com.tangem.blockchain.common.transaction.Fee
|
||||
import com.tangem.blockchain.common.transaction.TransactionFee
|
||||
import com.tangem.blockchainsdk.utils.toNetworkId
|
||||
import com.tangem.domain.models.currency.CryptoCurrency
|
||||
import com.tangem.domain.models.currency.CryptoCurrencyStatus
|
||||
import com.tangem.domain.tokens.model.FeePaidCurrency
|
||||
import com.tangem.domain.transaction.error.GetFeeError
|
||||
import com.tangem.domain.transaction.models.TransactionFeeExtended
|
||||
import com.tangem.feature.swap.domain.fee.CexFeeResult
|
||||
import com.tangem.feature.swap.domain.fee.DexFeeResult
|
||||
import com.tangem.feature.swap.domain.fee.TransactionFeeResult
|
||||
import com.tangem.feature.swap.domain.models.ExpressDataError
|
||||
import com.tangem.feature.swap.domain.models.SwapAmount
|
||||
import com.tangem.feature.swap.domain.models.domain.ExchangeProviderType
|
||||
import com.tangem.feature.swap.domain.models.domain.ExpressTransactionModel
|
||||
import com.tangem.feature.swap.domain.models.domain.SwapDataModel
|
||||
import com.tangem.feature.swap.domain.models.ui.FeeBucket
|
||||
import io.mockk.coEvery
|
||||
import io.mockk.coVerify
|
||||
import io.mockk.mockk
|
||||
import kotlinx.coroutines.test.runTest
|
||||
import org.junit.jupiter.api.BeforeEach
|
||||
import org.junit.jupiter.api.Test
|
||||
import org.junit.jupiter.api.TestInstance
|
||||
import java.math.BigDecimal
|
||||
import java.math.BigInteger
|
||||
|
||||
/**
|
||||
* Tests for [SwapInteractorImpl.loadSwapFee] ([REDACTED_TASK_KEY] — Phase 3).
|
||||
*
|
||||
* Exercises the unified fee API and verifies the four strategy branches:
|
||||
* - DEX-EVM: delegates to `DexSwapFeeCalculator` and returns `SwapFee` with `otherNativeFee=0`.
|
||||
* - DEX-Solana: same, no gas patch.
|
||||
* - DEX bridge with `otherNativeFee > 0`: propagated through `SwapFee.otherNativeFee`.
|
||||
* - CEX gasless-native (selectedFeeToken == null, gasless picks native).
|
||||
* - CEX gasless-token (selectedFeeToken == null, gasless picks token).
|
||||
* - CEX token-explicit (selectedFeeToken != null).
|
||||
* - DEX with swapData == null → `Left(GetFeeError.UnknownError)`.
|
||||
* - Zero amount → matches existing CEX/DEX paths (returns Left UnknownError).
|
||||
*
|
||||
* The DEX/CEX calculators themselves are mocked here — their internals are covered by
|
||||
* [com.tangem.feature.swap.domain.fee.DexSwapFeeCalculatorTest] and
|
||||
* [com.tangem.feature.swap.domain.fee.CexSwapFeeCalculatorTest].
|
||||
*/
|
||||
@TestInstance(TestInstance.Lifecycle.PER_CLASS)
|
||||
internal class SwapInteractorImplLoadSwapFeeTest : SwapInteractorImplTestBase() {
|
||||
|
||||
private val ethNetwork = Blockchain.Ethereum.toNetworkId()
|
||||
private val solanaNetwork = Blockchain.Solana.toNetworkId()
|
||||
|
||||
private val nativeFeeTokenStatus = mockk<CryptoCurrencyStatus>(relaxed = true)
|
||||
|
||||
@BeforeEach
|
||||
fun setup() {
|
||||
// `loadSwapFee` resolves the default `selectedFeeToken` via the fee-paid use case when
|
||||
// the caller passes null. Stub a concrete CryptoCurrencyStatus so the assertion is stable.
|
||||
coEvery {
|
||||
getFeePaidCryptoCurrencyStatusSyncUseCase.invoke(any(), any())
|
||||
} returns nativeFeeTokenStatus.right()
|
||||
}
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// DEX branch
|
||||
// -------------------------------------------------------------------------
|
||||
|
||||
@Test
|
||||
fun `DEX EVM delegates to DexSwapFeeCalculator and returns SwapFee with zero otherNativeFee`() = runTest {
|
||||
val fromStatus = buildSwapCurrencyStatus(networkRawId = ethNetwork, isCoin = true)
|
||||
val toStatus = buildSwapCurrencyStatus(networkRawId = ethNetwork, isCoin = true)
|
||||
val transaction = buildDexTransaction(otherNativeFeeWei = null)
|
||||
val swapData = SwapDataModel(
|
||||
toTokenAmount = SwapAmount(BigDecimal("0.5"), 18),
|
||||
transaction = transaction,
|
||||
)
|
||||
val rawFee = TransactionFee.Single(normal = mockk<Fee.Common>(relaxed = true))
|
||||
coEvery {
|
||||
dexSwapFeeCalculator.calculate(any(), any(), any())
|
||||
} returns DexFeeResult(
|
||||
transactionFee = TransactionFeeResult.Loaded(rawFee),
|
||||
otherNativeFee = BigDecimal.ZERO,
|
||||
gas = BigInteger.valueOf(21_000L),
|
||||
).right()
|
||||
|
||||
val result = sut.loadSwapFee(
|
||||
provider = buildSwapProvider(ExchangeProviderType.DEX),
|
||||
fromStatus = fromStatus,
|
||||
toStatus = toStatus,
|
||||
amount = SwapAmount(BigDecimal.ONE, 18),
|
||||
swapData = swapData,
|
||||
selectedFeeToken = null,
|
||||
)
|
||||
|
||||
assertThat(result.isRight()).isTrue()
|
||||
result.onRight { swapFee ->
|
||||
assertThat(swapFee.otherNativeFee).isEqualTo(BigDecimal.ZERO)
|
||||
assertThat(swapFee.transactionFeeResult).isInstanceOf(TransactionFeeResult.Loaded::class.java)
|
||||
assertThat(swapFee.feeBucket).isEqualTo(FeeBucket.MARKET)
|
||||
assertThat(swapFee.selectedFeeToken).isSameInstanceAs(nativeFeeTokenStatus)
|
||||
}
|
||||
coVerify(exactly = 1) {
|
||||
dexSwapFeeCalculator.calculate(fromStatus, transaction, null)
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `DEX Solana delegates to DexSwapFeeCalculator and propagates the loaded fee without gas patch`() = runTest {
|
||||
val fromStatus = buildSwapCurrencyStatus(networkRawId = solanaNetwork, isCoin = true, decimals = 9)
|
||||
val toStatus = buildSwapCurrencyStatus(networkRawId = solanaNetwork, isCoin = true, decimals = 9)
|
||||
val transaction = buildDexTransaction()
|
||||
val swapData = SwapDataModel(
|
||||
toTokenAmount = SwapAmount(BigDecimal("0.5"), 9),
|
||||
transaction = transaction,
|
||||
)
|
||||
val solanaFee = TransactionFee.Single(
|
||||
normal = Fee.Common(
|
||||
Amount(currencySymbol = "SOL", value = BigDecimal("0.005"), decimals = 9),
|
||||
),
|
||||
)
|
||||
coEvery {
|
||||
dexSwapFeeCalculator.calculate(any(), any(), any())
|
||||
} returns DexFeeResult(
|
||||
transactionFee = TransactionFeeResult.Loaded(solanaFee),
|
||||
otherNativeFee = BigDecimal.ZERO,
|
||||
gas = null,
|
||||
).right()
|
||||
|
||||
val result = sut.loadSwapFee(
|
||||
provider = buildSwapProvider(ExchangeProviderType.DEX),
|
||||
fromStatus = fromStatus,
|
||||
toStatus = toStatus,
|
||||
amount = SwapAmount(BigDecimal.ONE, 9),
|
||||
swapData = swapData,
|
||||
selectedFeeToken = null,
|
||||
)
|
||||
|
||||
assertThat(result.isRight()).isTrue()
|
||||
result.onRight { swapFee ->
|
||||
assertThat(swapFee.otherNativeFee).isEqualTo(BigDecimal.ZERO)
|
||||
assertThat(swapFee.fee).isEqualTo(solanaFee.normal)
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `DEX_BRIDGE propagates otherNativeFee from DexFeeResult to SwapFee`() = runTest {
|
||||
val fromStatus = buildSwapCurrencyStatus(networkRawId = ethNetwork, isCoin = true)
|
||||
val toStatus = buildSwapCurrencyStatus(networkRawId = ethNetwork, isCoin = true)
|
||||
val transaction = buildDexTransaction(otherNativeFeeWei = BigDecimal("500000000000000000"))
|
||||
val swapData = SwapDataModel(
|
||||
toTokenAmount = SwapAmount(BigDecimal("0.5"), 18),
|
||||
transaction = transaction,
|
||||
)
|
||||
coEvery {
|
||||
dexSwapFeeCalculator.calculate(any(), any(), any())
|
||||
} returns DexFeeResult(
|
||||
transactionFee = TransactionFeeResult.Loaded(
|
||||
TransactionFee.Single(normal = mockk<Fee.Common>(relaxed = true)),
|
||||
),
|
||||
otherNativeFee = BigDecimal("0.5"),
|
||||
gas = BigInteger.valueOf(21_000L),
|
||||
).right()
|
||||
|
||||
val result = sut.loadSwapFee(
|
||||
provider = buildSwapProvider(ExchangeProviderType.DEX_BRIDGE),
|
||||
fromStatus = fromStatus,
|
||||
toStatus = toStatus,
|
||||
amount = SwapAmount(BigDecimal.ONE, 18),
|
||||
swapData = swapData,
|
||||
selectedFeeToken = null,
|
||||
)
|
||||
|
||||
assertThat(result.isRight()).isTrue()
|
||||
result.onRight { swapFee ->
|
||||
assertThat(swapFee.otherNativeFee).isEquivalentAccordingToCompareTo(BigDecimal("0.5"))
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `DEX with swapData == null returns Left UnknownError without calling calculator`() = runTest {
|
||||
val fromStatus = buildSwapCurrencyStatus(networkRawId = ethNetwork, isCoin = true)
|
||||
val toStatus = buildSwapCurrencyStatus(networkRawId = ethNetwork, isCoin = true)
|
||||
|
||||
val result = sut.loadSwapFee(
|
||||
provider = buildSwapProvider(ExchangeProviderType.DEX),
|
||||
fromStatus = fromStatus,
|
||||
toStatus = toStatus,
|
||||
amount = SwapAmount(BigDecimal.ONE, 18),
|
||||
swapData = null,
|
||||
selectedFeeToken = null,
|
||||
)
|
||||
|
||||
assertThat(result.isLeft()).isTrue()
|
||||
result.onLeft { error ->
|
||||
assertThat(error).isInstanceOf(GetFeeError.UnknownError::class.java)
|
||||
}
|
||||
coVerify(exactly = 0) { dexSwapFeeCalculator.calculate(any(), any(), any()) }
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `DEX_BRIDGE with swapData == null returns Left UnknownError`() = runTest {
|
||||
val fromStatus = buildSwapCurrencyStatus(networkRawId = ethNetwork, isCoin = true)
|
||||
val toStatus = buildSwapCurrencyStatus(networkRawId = ethNetwork, isCoin = true)
|
||||
|
||||
val result = sut.loadSwapFee(
|
||||
provider = buildSwapProvider(ExchangeProviderType.DEX_BRIDGE),
|
||||
fromStatus = fromStatus,
|
||||
toStatus = toStatus,
|
||||
amount = SwapAmount(BigDecimal.ONE, 18),
|
||||
swapData = null,
|
||||
selectedFeeToken = null,
|
||||
)
|
||||
|
||||
assertThat(result.isLeft()).isTrue()
|
||||
result.onLeft { error ->
|
||||
assertThat(error).isInstanceOf(GetFeeError.UnknownError::class.java)
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `DEX calculator Left ExpressDataError maps to Wrapped Left GetFeeError`() = runTest {
|
||||
val fromStatus = buildSwapCurrencyStatus(networkRawId = ethNetwork, isCoin = true)
|
||||
val toStatus = buildSwapCurrencyStatus(networkRawId = ethNetwork, isCoin = true)
|
||||
val swapData = SwapDataModel(
|
||||
toTokenAmount = SwapAmount(BigDecimal("0.5"), 18),
|
||||
transaction = buildDexTransaction(),
|
||||
)
|
||||
coEvery {
|
||||
dexSwapFeeCalculator.calculate(any(), any(), any())
|
||||
} returns ExpressDataError.UnknownError().left()
|
||||
|
||||
val result = sut.loadSwapFee(
|
||||
provider = buildSwapProvider(ExchangeProviderType.DEX),
|
||||
fromStatus = fromStatus,
|
||||
toStatus = toStatus,
|
||||
amount = SwapAmount(BigDecimal.ONE, 18),
|
||||
swapData = swapData,
|
||||
selectedFeeToken = null,
|
||||
)
|
||||
|
||||
assertThat(result.isLeft()).isTrue()
|
||||
result.onLeft { error ->
|
||||
assertThat(error).isInstanceOf(GetFeeError.DataError::class.java)
|
||||
assertThat((error as? GetFeeError.DataError)?.cause).isInstanceOf(ExpressDataError.UnknownError::class.java)
|
||||
}
|
||||
}
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// CEX branch
|
||||
// -------------------------------------------------------------------------
|
||||
|
||||
@Test
|
||||
fun `CEX gasless-native delegates to CexSwapFeeCalculator and resolves native coin status`() = runTest {
|
||||
val fromStatus = buildSwapCurrencyStatus(networkRawId = ethNetwork, isCoin = false)
|
||||
val toStatus = buildSwapCurrencyStatus(networkRawId = ethNetwork, isCoin = true)
|
||||
val extendedFee = mockk<TransactionFeeExtended>(relaxed = true) {
|
||||
// Gasless picked native — feeTokenId points at the network's coin.
|
||||
io.mockk.every { transactionFee } returns TransactionFee.Single(
|
||||
normal = mockk<Fee.Common>(relaxed = true),
|
||||
)
|
||||
}
|
||||
coEvery {
|
||||
cexSwapFeeCalculator.calculate(any(), any(), any(), any())
|
||||
} returns CexFeeResult(
|
||||
transactionFee = TransactionFeeResult.LoadedExtended(extendedFee),
|
||||
).right()
|
||||
|
||||
val result = sut.loadSwapFee(
|
||||
provider = buildSwapProvider(ExchangeProviderType.CEX),
|
||||
fromStatus = fromStatus,
|
||||
toStatus = toStatus,
|
||||
amount = SwapAmount(BigDecimal.ONE, 18),
|
||||
swapData = null,
|
||||
selectedFeeToken = null,
|
||||
)
|
||||
|
||||
assertThat(result.isRight()).isTrue()
|
||||
result.onRight { swapFee ->
|
||||
assertThat(swapFee.transactionFeeResult).isInstanceOf(TransactionFeeResult.LoadedExtended::class.java)
|
||||
assertThat(swapFee.selectedFeeToken).isSameInstanceAs(nativeFeeTokenStatus)
|
||||
assertThat(swapFee.otherNativeFee).isEqualTo(BigDecimal.ZERO)
|
||||
}
|
||||
coVerify(exactly = 1) {
|
||||
cexSwapFeeCalculator.calculate(
|
||||
userWallet = fromStatus.userWallet,
|
||||
fromSwapCurrencyStatus = fromStatus,
|
||||
amount = BigDecimal.ONE,
|
||||
selectedFeeToken = null,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `CEX gasless-token (null selectedFeeToken, gasless picks token) returns native coin as fee token by default`() =
|
||||
runTest {
|
||||
// The unified contract here is: when caller passes null, the impl resolves the
|
||||
// native coin status via GetFeePaidCryptoCurrencyStatusSyncUseCase. The fact that
|
||||
// gasless internally picked a token does not change the SwapFee.selectedFeeToken
|
||||
// — that resolution is the caller's responsibility (it happens in Phase 4 when
|
||||
// FeeSelectorRepository builds the call).
|
||||
val fromStatus = buildSwapCurrencyStatus(networkRawId = ethNetwork, isCoin = false)
|
||||
val toStatus = buildSwapCurrencyStatus(networkRawId = ethNetwork, isCoin = true)
|
||||
val extendedFee = mockk<TransactionFeeExtended>(relaxed = true)
|
||||
coEvery {
|
||||
cexSwapFeeCalculator.calculate(any(), any(), any(), any())
|
||||
} returns CexFeeResult(
|
||||
transactionFee = TransactionFeeResult.LoadedExtended(extendedFee),
|
||||
).right()
|
||||
|
||||
val result = sut.loadSwapFee(
|
||||
provider = buildSwapProvider(ExchangeProviderType.CEX),
|
||||
fromStatus = fromStatus,
|
||||
toStatus = toStatus,
|
||||
amount = SwapAmount(BigDecimal.ONE, 18),
|
||||
swapData = null,
|
||||
selectedFeeToken = null,
|
||||
)
|
||||
|
||||
assertThat(result.isRight()).isTrue()
|
||||
result.onRight { swapFee ->
|
||||
assertThat(swapFee.selectedFeeToken).isSameInstanceAs(nativeFeeTokenStatus)
|
||||
assertThat(swapFee.transactionFeeResult).isInstanceOf(TransactionFeeResult.LoadedExtended::class.java)
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `CEX token-explicit propagates the provided selectedFeeToken into SwapFee`() = runTest {
|
||||
val fromStatus = buildSwapCurrencyStatus(networkRawId = ethNetwork, isCoin = false)
|
||||
val toStatus = buildSwapCurrencyStatus(networkRawId = ethNetwork, isCoin = true)
|
||||
val explicitTokenStatus = mockk<CryptoCurrencyStatus>(relaxed = true) {
|
||||
io.mockk.every { currency } returns mockk<CryptoCurrency.Token>(relaxed = true)
|
||||
}
|
||||
val extendedFee = mockk<TransactionFeeExtended>(relaxed = true)
|
||||
coEvery {
|
||||
cexSwapFeeCalculator.calculate(any(), any(), any(), any())
|
||||
} returns CexFeeResult(
|
||||
transactionFee = TransactionFeeResult.LoadedExtended(extendedFee),
|
||||
).right()
|
||||
|
||||
val result = sut.loadSwapFee(
|
||||
provider = buildSwapProvider(ExchangeProviderType.CEX),
|
||||
fromStatus = fromStatus,
|
||||
toStatus = toStatus,
|
||||
amount = SwapAmount(BigDecimal.ONE, 18),
|
||||
swapData = null,
|
||||
selectedFeeToken = explicitTokenStatus,
|
||||
)
|
||||
|
||||
assertThat(result.isRight()).isTrue()
|
||||
result.onRight { swapFee ->
|
||||
assertThat(swapFee.selectedFeeToken).isSameInstanceAs(explicitTokenStatus)
|
||||
}
|
||||
coVerify(exactly = 1) {
|
||||
cexSwapFeeCalculator.calculate(
|
||||
userWallet = fromStatus.userWallet,
|
||||
fromSwapCurrencyStatus = fromStatus,
|
||||
amount = BigDecimal.ONE,
|
||||
selectedFeeToken = explicitTokenStatus,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `CEX explicit native selectedFeeToken returns SwapFee with Loaded fee result`() = runTest {
|
||||
val fromStatus = buildSwapCurrencyStatus(networkRawId = ethNetwork, isCoin = true)
|
||||
val toStatus = buildSwapCurrencyStatus(networkRawId = ethNetwork, isCoin = true)
|
||||
val explicitNativeStatus = mockk<CryptoCurrencyStatus>(relaxed = true) {
|
||||
io.mockk.every { currency } returns mockk<CryptoCurrency.Coin>(relaxed = true)
|
||||
}
|
||||
val rawFee = TransactionFee.Single(normal = mockk<Fee.Common>(relaxed = true))
|
||||
coEvery {
|
||||
cexSwapFeeCalculator.calculate(any(), any(), any(), any())
|
||||
} returns CexFeeResult(
|
||||
transactionFee = TransactionFeeResult.Loaded(rawFee),
|
||||
).right()
|
||||
|
||||
val result = sut.loadSwapFee(
|
||||
provider = buildSwapProvider(ExchangeProviderType.CEX),
|
||||
fromStatus = fromStatus,
|
||||
toStatus = toStatus,
|
||||
amount = SwapAmount(BigDecimal.ONE, 18),
|
||||
swapData = null,
|
||||
selectedFeeToken = explicitNativeStatus,
|
||||
)
|
||||
|
||||
assertThat(result.isRight()).isTrue()
|
||||
result.onRight { swapFee ->
|
||||
assertThat(swapFee.selectedFeeToken).isSameInstanceAs(explicitNativeStatus)
|
||||
assertThat(swapFee.transactionFeeResult).isInstanceOf(TransactionFeeResult.Loaded::class.java)
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `CEX calculator Left UnknownError propagates as Left UnknownError`() = runTest {
|
||||
val fromStatus = buildSwapCurrencyStatus(networkRawId = ethNetwork, isCoin = true)
|
||||
val toStatus = buildSwapCurrencyStatus(networkRawId = ethNetwork, isCoin = true)
|
||||
coEvery {
|
||||
cexSwapFeeCalculator.calculate(any(), any(), any(), any())
|
||||
} returns GetFeeError.UnknownError.left()
|
||||
|
||||
val result = sut.loadSwapFee(
|
||||
provider = buildSwapProvider(ExchangeProviderType.CEX),
|
||||
fromStatus = fromStatus,
|
||||
toStatus = toStatus,
|
||||
amount = SwapAmount(BigDecimal.ONE, 18),
|
||||
swapData = null,
|
||||
selectedFeeToken = null,
|
||||
)
|
||||
|
||||
assertThat(result.isLeft()).isTrue()
|
||||
result.onLeft { error ->
|
||||
assertThat(error).isInstanceOf(GetFeeError.UnknownError::class.java)
|
||||
}
|
||||
}
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// Zero-amount short-circuit
|
||||
// -------------------------------------------------------------------------
|
||||
|
||||
@Test
|
||||
fun `amount zero on CEX returns Left UnknownError without calling calculator`() = runTest {
|
||||
val fromStatus = buildSwapCurrencyStatus(networkRawId = ethNetwork, isCoin = true)
|
||||
val toStatus = buildSwapCurrencyStatus(networkRawId = ethNetwork, isCoin = true)
|
||||
|
||||
val result = sut.loadSwapFee(
|
||||
provider = buildSwapProvider(ExchangeProviderType.CEX),
|
||||
fromStatus = fromStatus,
|
||||
toStatus = toStatus,
|
||||
amount = SwapAmount(BigDecimal.ZERO, 18),
|
||||
swapData = null,
|
||||
selectedFeeToken = null,
|
||||
)
|
||||
|
||||
assertThat(result.isLeft()).isTrue()
|
||||
result.onLeft { error ->
|
||||
assertThat(error).isInstanceOf(GetFeeError.UnknownError::class.java)
|
||||
}
|
||||
coVerify(exactly = 0) { cexSwapFeeCalculator.calculate(any(), any(), any(), any()) }
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `amount zero on DEX returns Left UnknownError without calling calculator`() = runTest {
|
||||
val fromStatus = buildSwapCurrencyStatus(networkRawId = ethNetwork, isCoin = true)
|
||||
val toStatus = buildSwapCurrencyStatus(networkRawId = ethNetwork, isCoin = true)
|
||||
val swapData = SwapDataModel(
|
||||
toTokenAmount = SwapAmount(BigDecimal("0.5"), 18),
|
||||
transaction = buildDexTransaction(),
|
||||
)
|
||||
|
||||
val result = sut.loadSwapFee(
|
||||
provider = buildSwapProvider(ExchangeProviderType.DEX),
|
||||
fromStatus = fromStatus,
|
||||
toStatus = toStatus,
|
||||
amount = SwapAmount(BigDecimal.ZERO, 18),
|
||||
swapData = swapData,
|
||||
selectedFeeToken = null,
|
||||
)
|
||||
|
||||
assertThat(result.isLeft()).isTrue()
|
||||
result.onLeft { error ->
|
||||
assertThat(error).isInstanceOf(GetFeeError.UnknownError::class.java)
|
||||
}
|
||||
coVerify(exactly = 0) { dexSwapFeeCalculator.calculate(any(), any(), any()) }
|
||||
}
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// DEX with explicit selectedFeeToken (Token)
|
||||
// -------------------------------------------------------------------------
|
||||
|
||||
@Test
|
||||
fun `DEX with explicit token selectedFeeToken propagates it into SwapFee and calls calculator with that token`() =
|
||||
runTest {
|
||||
val fromStatus = buildSwapCurrencyStatus(networkRawId = ethNetwork, isCoin = false)
|
||||
val toStatus = buildSwapCurrencyStatus(networkRawId = ethNetwork, isCoin = true)
|
||||
val transaction = buildDexTransaction()
|
||||
val swapData = SwapDataModel(
|
||||
toTokenAmount = SwapAmount(BigDecimal("0.5"), 18),
|
||||
transaction = transaction,
|
||||
)
|
||||
val explicitTokenStatus = mockk<CryptoCurrencyStatus>(relaxed = true) {
|
||||
io.mockk.every { currency } returns mockk<CryptoCurrency.Token>(relaxed = true)
|
||||
}
|
||||
val rawFee = TransactionFee.Single(normal = mockk<Fee.Common>(relaxed = true))
|
||||
coEvery {
|
||||
dexSwapFeeCalculator.calculate(any(), any(), any())
|
||||
} returns DexFeeResult(
|
||||
transactionFee = TransactionFeeResult.Loaded(rawFee),
|
||||
otherNativeFee = BigDecimal.ZERO,
|
||||
gas = BigInteger.valueOf(21_000L),
|
||||
).right()
|
||||
|
||||
val result = sut.loadSwapFee(
|
||||
provider = buildSwapProvider(ExchangeProviderType.DEX),
|
||||
fromStatus = fromStatus,
|
||||
toStatus = toStatus,
|
||||
amount = SwapAmount(BigDecimal.ONE, 18),
|
||||
swapData = swapData,
|
||||
selectedFeeToken = explicitTokenStatus,
|
||||
)
|
||||
|
||||
assertThat(result.isRight()).isTrue()
|
||||
result.onRight { swapFee ->
|
||||
assertThat(swapFee.selectedFeeToken).isSameInstanceAs(explicitTokenStatus)
|
||||
}
|
||||
coVerify(exactly = 1) {
|
||||
dexSwapFeeCalculator.calculate(fromStatus, transaction, explicitTokenStatus)
|
||||
}
|
||||
}
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// resolveNativeFeeTokenStatus failure path
|
||||
// -------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* When selectedFeeToken is null AND getFeePaidCryptoCurrencyStatusSyncUseCase returns Right(null),
|
||||
* the impl falls back to building a CryptoCurrencyStatus from scratch.
|
||||
* If networkAddress is null on the fromStatus, the fallback returns null and
|
||||
* loadSwapFee must return Left(UnknownError).
|
||||
*
|
||||
* This exercises the `resolveNativeFeeTokenStatus` fallback path in loadDexSwapFee.
|
||||
*/
|
||||
@Test
|
||||
fun `DEX with null selectedFeeToken — resolveNativeFeeTokenStatus returns null when networkAddress is null`() =
|
||||
runTest {
|
||||
// Primary resolve: getFeePaidCryptoCurrencyStatusSyncUseCase returns Right(null)
|
||||
// → triggers the fallback block in resolveNativeFeeTokenStatus
|
||||
coEvery {
|
||||
getFeePaidCryptoCurrencyStatusSyncUseCase.invoke(any(), any())
|
||||
} returns null.right()
|
||||
|
||||
// The fallback path tries to build a CryptoCurrencyStatus.NoQuote/Loaded
|
||||
// but requires networkAddress to be non-null. Stub it to null so the
|
||||
// fallback's early-return fires → resolveNativeFeeTokenStatus returns null.
|
||||
val fromStatusWithNullAddr = buildSwapCurrencyStatus(
|
||||
networkRawId = ethNetwork,
|
||||
isCoin = true,
|
||||
)
|
||||
io.mockk.every {
|
||||
fromStatusWithNullAddr.status.value.networkAddress
|
||||
} returns null
|
||||
|
||||
val toStatus = buildSwapCurrencyStatus(networkRawId = ethNetwork, isCoin = true)
|
||||
val swapData = SwapDataModel(
|
||||
toTokenAmount = SwapAmount(BigDecimal("0.5"), 18),
|
||||
transaction = buildDexTransaction(),
|
||||
)
|
||||
coEvery { currenciesRepository.getFeePaidCurrency(any(), any()) } returns FeePaidCurrency.Coin
|
||||
coEvery { currenciesRepository.createCoinCurrency(any()) } returns buildCoinCurrency()
|
||||
coEvery { walletManagersFacade.getNativeTokenBalance(any(), any(), any()) } returns BigDecimal("1.0")
|
||||
// Make the calculator succeed (so the failure comes from resolveNativeFeeTokenStatus).
|
||||
// quotesRepository returns null → NoQuote path → networkAddress null → return@run null
|
||||
coEvery { quotesRepository.getMultiQuoteSyncOrNull(any()) } returns null
|
||||
coEvery {
|
||||
dexSwapFeeCalculator.calculate(any(), any(), any())
|
||||
} returns DexFeeResult(
|
||||
transactionFee = TransactionFeeResult.Loaded(
|
||||
TransactionFee.Single(normal = mockk<Fee.Common>(relaxed = true)),
|
||||
),
|
||||
otherNativeFee = BigDecimal.ZERO,
|
||||
gas = BigInteger.valueOf(21_000L),
|
||||
).right()
|
||||
|
||||
val result = sut.loadSwapFee(
|
||||
provider = buildSwapProvider(ExchangeProviderType.DEX),
|
||||
fromStatus = fromStatusWithNullAddr,
|
||||
toStatus = toStatus,
|
||||
amount = SwapAmount(BigDecimal.ONE, 18),
|
||||
swapData = swapData,
|
||||
selectedFeeToken = null,
|
||||
)
|
||||
|
||||
// When resolveNativeFeeTokenStatus returns null → Left(UnknownError)
|
||||
assertThat(result.isLeft()).isTrue()
|
||||
result.onLeft { error ->
|
||||
assertThat(error).isInstanceOf(GetFeeError.UnknownError::class.java)
|
||||
}
|
||||
}
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// Helpers
|
||||
// -------------------------------------------------------------------------
|
||||
|
||||
private fun buildDexTransaction(
|
||||
otherNativeFeeWei: BigDecimal? = null,
|
||||
): ExpressTransactionModel.DEX = ExpressTransactionModel.DEX(
|
||||
fromAmount = SwapAmount(BigDecimal.ONE, 18),
|
||||
toAmount = SwapAmount(BigDecimal("0.5"), 18),
|
||||
txValue = "1000000000000000",
|
||||
txId = "tx-id",
|
||||
txTo = "0xTo",
|
||||
txExtraId = null,
|
||||
txFrom = "0xFrom",
|
||||
txData = "dGVzdA==",
|
||||
otherNativeFeeWei = otherNativeFeeWei,
|
||||
gas = BigInteger.valueOf(21_000L),
|
||||
)
|
||||
}
|
||||
File diff suppressed because it is too large
Load diff
|
|
@ -28,25 +28,23 @@ import com.tangem.domain.swap.models.SwapPairModel
|
|||
import com.tangem.domain.swap.usecase.GetSwapPairUseCase
|
||||
import com.tangem.domain.tokens.GetAssetRequirementsUseCase
|
||||
import com.tangem.domain.tokens.GetCurrencyCheckUseCase
|
||||
import com.tangem.domain.tokens.MultiWalletCryptoCurrenciesSupplier
|
||||
import com.tangem.domain.tokens.repository.CurrenciesRepository
|
||||
import com.tangem.domain.tokens.repository.CurrencyChecksRepository
|
||||
import com.tangem.domain.transaction.usecase.*
|
||||
import com.tangem.domain.transaction.usecase.gasless.CreateAndSendGaslessTransactionUseCase
|
||||
import com.tangem.domain.transaction.usecase.gasless.EstimateFeeForGaslessTxUseCase
|
||||
import com.tangem.domain.transaction.usecase.gasless.EstimateFeeForTokenUseCase
|
||||
import com.tangem.domain.transaction.usecase.gasless.GetFeeForTokenUseCase
|
||||
import com.tangem.domain.walletmanager.WalletManagersFacade
|
||||
import com.tangem.feature.swap.domain.api.SwapRepository
|
||||
import com.tangem.feature.swap.domain.fee.CexSwapFeeCalculator
|
||||
import com.tangem.feature.swap.domain.fee.DexSwapFeeCalculator
|
||||
import com.tangem.feature.swap.domain.fee.TransactionFeeResult
|
||||
import com.tangem.feature.swap.domain.models.SwapAmount
|
||||
import com.tangem.feature.swap.domain.models.domain.*
|
||||
import com.tangem.feature.swap.domain.models.ui.AmountFormatter
|
||||
import com.tangem.feature.swap.domain.models.ui.TxFee
|
||||
import com.tangem.feature.swap.domain.models.ui.SwapFee
|
||||
import io.mockk.clearAllMocks
|
||||
import io.mockk.every
|
||||
import io.mockk.mockk
|
||||
import io.mockk.unmockkAll
|
||||
import org.junit.jupiter.api.AfterAll
|
||||
import org.junit.jupiter.api.AfterEach
|
||||
import java.math.BigDecimal
|
||||
|
||||
|
|
@ -69,19 +67,12 @@ internal open class SwapInteractorImplTestBase {
|
|||
protected val quotesRepository: QuotesRepository = mockk(relaxed = true)
|
||||
protected val multiQuoteStatusFetcher: MultiQuoteStatusFetcher = mockk(relaxed = true)
|
||||
protected val swapTransactionRepository: SwapTransactionRepository = mockk(relaxed = true)
|
||||
private val currencyChecksRepository: CurrencyChecksRepository = mockk(relaxed = true)
|
||||
protected val currencyChecksRepository: CurrencyChecksRepository = mockk(relaxed = true)
|
||||
private val appCurrencyRepository: AppCurrencyRepository = mockk(relaxed = true)
|
||||
protected val currenciesRepository: CurrenciesRepository = mockk(relaxed = true)
|
||||
protected val multiWalletCryptoCurrenciesSupplier: MultiWalletCryptoCurrenciesSupplier = mockk(relaxed = true)
|
||||
protected val validateTransactionUseCase: ValidateTransactionUseCase = mockk(relaxed = true)
|
||||
protected val estimateFeeUseCase: EstimateFeeUseCase = mockk(relaxed = true)
|
||||
protected val estimateFeeForTokenUseCase: EstimateFeeForTokenUseCase = mockk(relaxed = true)
|
||||
protected val estimateFeeForGaslessTxUseCase: EstimateFeeForGaslessTxUseCase = mockk(relaxed = true)
|
||||
private val getFeeForTokenUseCase: GetFeeForTokenUseCase = mockk(relaxed = true)
|
||||
protected val createAndSendGaslessTransactionUseCase: CreateAndSendGaslessTransactionUseCase =
|
||||
mockk(relaxed = true)
|
||||
protected val getFeeUseCase: GetFeeUseCase = mockk(relaxed = true)
|
||||
private val getEthSpecificFeeUseCase: GetEthSpecificFeeUseCase = mockk(relaxed = true)
|
||||
protected val getCurrencyCheckUseCase: GetCurrencyCheckUseCase = mockk(relaxed = true)
|
||||
protected val getAssetRequirementsUseCase: GetAssetRequirementsUseCase = mockk(relaxed = true)
|
||||
protected val amountFormatter: AmountFormatter = mockk(relaxed = true)
|
||||
|
|
@ -91,6 +82,8 @@ internal open class SwapInteractorImplTestBase {
|
|||
protected val walletManagersFacade: WalletManagersFacade = mockk(relaxed = true)
|
||||
protected val getAllowanceInfoUseCase: GetAllowanceInfoUseCase = mockk(relaxed = true)
|
||||
protected val getSwapPairUseCase: GetSwapPairUseCase = mockk(relaxed = true)
|
||||
protected val dexSwapFeeCalculator: DexSwapFeeCalculator = mockk(relaxed = true)
|
||||
protected val cexSwapFeeCalculator: CexSwapFeeCalculator = mockk(relaxed = true)
|
||||
|
||||
// endregion
|
||||
|
||||
|
|
@ -110,15 +103,8 @@ internal open class SwapInteractorImplTestBase {
|
|||
currencyChecksRepository = currencyChecksRepository,
|
||||
appCurrencyRepository = appCurrencyRepository,
|
||||
currenciesRepository = currenciesRepository,
|
||||
multiWalletCryptoCurrenciesSupplier = multiWalletCryptoCurrenciesSupplier,
|
||||
validateTransactionUseCase = validateTransactionUseCase,
|
||||
estimateFeeUseCase = estimateFeeUseCase,
|
||||
estimateFeeForTokenUseCase = estimateFeeForTokenUseCase,
|
||||
estimateFeeForGaslessTxUseCase = estimateFeeForGaslessTxUseCase,
|
||||
getFeeForTokenUseCase = getFeeForTokenUseCase,
|
||||
createAndSendGaslessTransactionUseCase = createAndSendGaslessTransactionUseCase,
|
||||
getFeeUseCase = getFeeUseCase,
|
||||
getEthSpecificFeeUseCase = getEthSpecificFeeUseCase,
|
||||
getCurrencyCheckUseCase = getCurrencyCheckUseCase,
|
||||
getAssetRequirementsUseCase = getAssetRequirementsUseCase,
|
||||
amountFormatter = amountFormatter,
|
||||
|
|
@ -127,6 +113,8 @@ internal open class SwapInteractorImplTestBase {
|
|||
walletManagersFacade = walletManagersFacade,
|
||||
getAllowanceInfoUseCase = getAllowanceInfoUseCase,
|
||||
getSwapPairUseCase = getSwapPairUseCase,
|
||||
dexSwapFeeCalculator = dexSwapFeeCalculator,
|
||||
cexSwapFeeCalculator = cexSwapFeeCalculator,
|
||||
)
|
||||
}
|
||||
|
||||
|
|
@ -146,19 +134,6 @@ internal open class SwapInteractorImplTestBase {
|
|||
clearAllMocks()
|
||||
unmockkAll()
|
||||
}
|
||||
|
||||
/**
|
||||
* Defensive shutdown hook — releases any remaining `mockkStatic` / `mockkObject` declarations
|
||||
* after the entire test class finishes, in case `@AfterEach` was bypassed (e.g. JVM shutdown
|
||||
* during a hard crash).
|
||||
*
|
||||
* Requires `@TestInstance(Lifecycle.PER_CLASS)` on every subclass — already the case across
|
||||
* all `SwapInteractorImpl*Test` classes.
|
||||
*/
|
||||
@AfterAll
|
||||
open fun releaseStaticMocksAfterAllTests() {
|
||||
unmockkAll()
|
||||
}
|
||||
}
|
||||
|
||||
// region — Test Builders
|
||||
|
|
@ -298,37 +273,32 @@ internal fun buildSwapProvider(
|
|||
)
|
||||
|
||||
/**
|
||||
* Builds a [TxFee.FeeComponent] wrapping a [Fee.Common] with the given fiat-equivalent amount.
|
||||
* Builds a [SwapFee] wrapping a [Fee.Common] with the given fiat-equivalent amount.
|
||||
*/
|
||||
internal fun buildTxFee(
|
||||
internal fun buildSwapFee(
|
||||
feeValue: BigDecimal = BigDecimal("0.001"),
|
||||
selectedToken: CryptoCurrencyStatus? = null,
|
||||
): TxFee.FeeComponent {
|
||||
selectedFeeToken: CryptoCurrencyStatus = buildSwapCurrencyStatus().status,
|
||||
otherNativeFee: BigDecimal = BigDecimal.ZERO,
|
||||
feeBucket: com.tangem.feature.swap.domain.models.ui.FeeBucket =
|
||||
com.tangem.feature.swap.domain.models.ui.FeeBucket.MARKET,
|
||||
): SwapFee {
|
||||
val amount = mockk<Amount>(relaxed = true) {
|
||||
every { value } returns feeValue
|
||||
}
|
||||
val fee = mockk<Fee.Common>(relaxed = true) {
|
||||
every { this@mockk.amount } returns amount
|
||||
}
|
||||
return TxFee.FeeComponent(
|
||||
return SwapFee(
|
||||
fee = fee,
|
||||
transactionFeeResult = TransactionFeeResult.Loaded(
|
||||
fee = mockk<TransactionFee.Single>(relaxed = true),
|
||||
),
|
||||
selectedToken = selectedToken,
|
||||
selectedFeeToken = selectedFeeToken,
|
||||
otherNativeFee = otherNativeFee,
|
||||
feeBucket = feeBucket,
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Builds a [TxFeeSealedState.Component] wrapping a [TxFee.FeeComponent].
|
||||
*/
|
||||
internal fun buildTxFeeSealedState(
|
||||
feeValue: BigDecimal = BigDecimal("0.001"),
|
||||
selectedToken: CryptoCurrencyStatus? = null,
|
||||
): TxFeeSealedState = TxFeeSealedState.Component(
|
||||
txFee = buildTxFee(feeValue = feeValue, selectedToken = selectedToken),
|
||||
)
|
||||
|
||||
/**
|
||||
* Builds a [SwapPairLeast] with matching from/to network+contract pairs.
|
||||
*/
|
||||
|
|
|
|||
|
|
@ -0,0 +1,368 @@
|
|||
package com.tangem.feature.swap.domain.fee
|
||||
|
||||
import arrow.core.left
|
||||
import arrow.core.right
|
||||
import com.google.common.truth.Truth.assertThat
|
||||
import com.tangem.blockchain.common.Amount
|
||||
import com.tangem.blockchain.common.Blockchain
|
||||
import com.tangem.blockchain.common.transaction.Fee
|
||||
import com.tangem.blockchain.common.transaction.TransactionFee
|
||||
import com.tangem.blockchainsdk.utils.toNetworkId
|
||||
import com.tangem.domain.models.currency.CryptoCurrency
|
||||
import com.tangem.domain.models.currency.CryptoCurrencyStatus
|
||||
import com.tangem.domain.models.wallet.UserWallet
|
||||
import com.tangem.domain.transaction.error.GetFeeError
|
||||
import com.tangem.domain.transaction.models.TransactionFeeExtended
|
||||
import com.tangem.domain.transaction.usecase.EstimateFeeUseCase
|
||||
import com.tangem.domain.transaction.usecase.gasless.EstimateFeeForGaslessTxUseCase
|
||||
import com.tangem.domain.transaction.usecase.gasless.EstimateFeeForTokenUseCase
|
||||
import com.tangem.feature.swap.domain.buildSwapCurrencyStatus
|
||||
import io.mockk.*
|
||||
import kotlinx.coroutines.test.runTest
|
||||
import org.junit.jupiter.api.AfterEach
|
||||
import org.junit.jupiter.api.Test
|
||||
import org.junit.jupiter.api.TestInstance
|
||||
import java.math.BigDecimal
|
||||
import java.math.BigInteger
|
||||
|
||||
/**
|
||||
* Unit tests for [CexSwapFeeCalculator].
|
||||
*
|
||||
* Mirrors the CEX paths in `SwapInteractorImpl.loadFeeForSwapTransaction` (overload 2 native +
|
||||
* overload 1 token/gasless) and `getFeeForCex`, but exercises the new helper directly.
|
||||
*/
|
||||
@TestInstance(TestInstance.Lifecycle.PER_CLASS)
|
||||
internal class CexSwapFeeCalculatorTest {
|
||||
|
||||
private val ethNetwork = Blockchain.Ethereum.toNetworkId()
|
||||
|
||||
private val estimateFeeUseCase: EstimateFeeUseCase = mockk(relaxed = true)
|
||||
private val estimateFeeForTokenUseCase: EstimateFeeForTokenUseCase = mockk(relaxed = true)
|
||||
private val estimateFeeForGaslessTxUseCase: EstimateFeeForGaslessTxUseCase = mockk(relaxed = true)
|
||||
|
||||
private val sendBump = PatchEthGasLimitForSwap(percentage = PatchEthGasLimitForSwap.SEND_PERCENTAGE)
|
||||
|
||||
private val sut: CexSwapFeeCalculator by lazy {
|
||||
CexSwapFeeCalculator(
|
||||
estimateFeeUseCase = estimateFeeUseCase,
|
||||
estimateFeeForTokenUseCase = estimateFeeForTokenUseCase,
|
||||
estimateFeeForGaslessTxUseCase = estimateFeeForGaslessTxUseCase,
|
||||
patchEthGasLimitForSwap = sendBump,
|
||||
)
|
||||
}
|
||||
|
||||
@AfterEach
|
||||
fun tearDown() {
|
||||
clearAllMocks()
|
||||
}
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// Zero amount short-circuit
|
||||
// -------------------------------------------------------------------------
|
||||
|
||||
@Test
|
||||
fun `GIVEN zero amount WHEN calculate THEN returns Left UnknownError`() = runTest {
|
||||
val fromStatus = buildSwapCurrencyStatus(networkRawId = ethNetwork)
|
||||
|
||||
val result = sut.calculate(
|
||||
userWallet = fromStatus.userWallet,
|
||||
fromSwapCurrencyStatus = fromStatus,
|
||||
amount = BigDecimal.ZERO,
|
||||
selectedFeeToken = null,
|
||||
)
|
||||
|
||||
assertThat(result.isLeft()).isTrue()
|
||||
result.onLeft { assertThat(it).isInstanceOf(GetFeeError.UnknownError::class.java) }
|
||||
// None of the fee use cases were invoked
|
||||
coVerify(exactly = 0) {
|
||||
estimateFeeUseCase.invoke(any(), any(), any())
|
||||
estimateFeeForTokenUseCase.invoke(any(), any(), any(), any())
|
||||
estimateFeeForGaslessTxUseCase.invoke(any(), any(), any())
|
||||
}
|
||||
}
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// Gasless path (selectedFeeToken == null)
|
||||
// -------------------------------------------------------------------------
|
||||
|
||||
@Test
|
||||
fun `GIVEN null selectedFeeToken WHEN calculate THEN delegates to estimateFeeForGaslessTxUseCase`() = runTest {
|
||||
val fromStatus = buildSwapCurrencyStatus(networkRawId = ethNetwork)
|
||||
val expected = mockk<TransactionFeeExtended>(relaxed = true)
|
||||
coEvery {
|
||||
estimateFeeForGaslessTxUseCase(any(), any(), any())
|
||||
} returns expected.right()
|
||||
|
||||
val result = sut.calculate(
|
||||
userWallet = fromStatus.userWallet,
|
||||
fromSwapCurrencyStatus = fromStatus,
|
||||
amount = BigDecimal("1.5"),
|
||||
selectedFeeToken = null,
|
||||
)
|
||||
|
||||
assertThat(result.isRight()).isTrue()
|
||||
result.onRight { cexResult ->
|
||||
val loaded = cexResult.transactionFee as TransactionFeeResult.LoadedExtended
|
||||
assertThat(loaded.fee).isSameInstanceAs(expected)
|
||||
}
|
||||
coVerify(exactly = 1) {
|
||||
estimateFeeForGaslessTxUseCase.invoke(
|
||||
amount = BigDecimal("1.5"),
|
||||
userWallet = fromStatus.userWallet,
|
||||
sendingTokenCurrencyStatus = fromStatus.status,
|
||||
)
|
||||
}
|
||||
// Other use cases are NOT called.
|
||||
coVerify(exactly = 0) {
|
||||
estimateFeeUseCase.invoke(any(), any(), any())
|
||||
estimateFeeForTokenUseCase.invoke(any(), any(), any(), any())
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `GIVEN gasless path returns Left WHEN calculate THEN error is propagated`() = runTest {
|
||||
val fromStatus = buildSwapCurrencyStatus(networkRawId = ethNetwork)
|
||||
coEvery {
|
||||
estimateFeeForGaslessTxUseCase(any(), any(), any())
|
||||
} returns GetFeeError.GaslessError.NoSupportedTokensFound.left()
|
||||
|
||||
val result = sut.calculate(
|
||||
userWallet = fromStatus.userWallet,
|
||||
fromSwapCurrencyStatus = fromStatus,
|
||||
amount = BigDecimal("1.0"),
|
||||
selectedFeeToken = null,
|
||||
)
|
||||
|
||||
assertThat(result.isLeft()).isTrue()
|
||||
result.onLeft { error ->
|
||||
assertThat(error).isInstanceOf(GetFeeError.GaslessError.NoSupportedTokensFound::class.java)
|
||||
}
|
||||
}
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// Explicit token path (selectedFeeToken is Token)
|
||||
// -------------------------------------------------------------------------
|
||||
|
||||
@Test
|
||||
fun `GIVEN explicit token selectedFeeToken WHEN calculate THEN delegates to estimateFeeForTokenUseCase`() =
|
||||
runTest {
|
||||
val fromStatus = buildSwapCurrencyStatus(networkRawId = ethNetwork)
|
||||
val tokenCurrency = mockk<CryptoCurrency.Token>(relaxed = true)
|
||||
val tokenStatus = mockk<CryptoCurrencyStatus>(relaxed = true) {
|
||||
every { currency } returns tokenCurrency
|
||||
}
|
||||
val expected = mockk<TransactionFeeExtended>(relaxed = true)
|
||||
coEvery {
|
||||
estimateFeeForTokenUseCase(any(), any(), any(), any())
|
||||
} returns expected.right()
|
||||
|
||||
val result = sut.calculate(
|
||||
userWallet = fromStatus.userWallet,
|
||||
fromSwapCurrencyStatus = fromStatus,
|
||||
amount = BigDecimal("2.0"),
|
||||
selectedFeeToken = tokenStatus,
|
||||
)
|
||||
|
||||
assertThat(result.isRight()).isTrue()
|
||||
result.onRight { cexResult ->
|
||||
val loaded = cexResult.transactionFee as TransactionFeeResult.LoadedExtended
|
||||
assertThat(loaded.fee).isSameInstanceAs(expected)
|
||||
}
|
||||
coVerify(exactly = 1) {
|
||||
estimateFeeForTokenUseCase.invoke(
|
||||
userWallet = fromStatus.userWallet,
|
||||
feeTokenCurrencyStatus = tokenStatus,
|
||||
sendingTokenCurrencyStatus = fromStatus.status,
|
||||
amount = BigDecimal("2.0"),
|
||||
)
|
||||
}
|
||||
coVerify(exactly = 0) {
|
||||
estimateFeeUseCase.invoke(any(), any(), any())
|
||||
estimateFeeForGaslessTxUseCase.invoke(any(), any(), any())
|
||||
}
|
||||
}
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// Explicit native path (selectedFeeToken is Coin) — applies 5% bump
|
||||
// -------------------------------------------------------------------------
|
||||
|
||||
@Test
|
||||
fun `GIVEN explicit native selectedFeeToken WHEN calculate THEN delegates to estimateFeeUseCase and applies 5 percent bump on Ethereum`() =
|
||||
runTest {
|
||||
val fromStatus = buildSwapCurrencyStatus(networkRawId = ethNetwork)
|
||||
val coinCurrency = mockk<CryptoCurrency.Coin>(relaxed = true)
|
||||
val coinStatus = mockk<CryptoCurrencyStatus>(relaxed = true) {
|
||||
every { currency } returns coinCurrency
|
||||
}
|
||||
val rawFee = Fee.Ethereum.Legacy(
|
||||
amount = Amount(currencySymbol = "ETH", value = BigDecimal("0.000002"), decimals = 18),
|
||||
gasLimit = BigInteger.valueOf(100_000),
|
||||
gasPrice = BigInteger.valueOf(20_000_000_000),
|
||||
)
|
||||
coEvery {
|
||||
estimateFeeUseCase(any(), any(), any())
|
||||
} returns TransactionFee.Single(normal = rawFee).right()
|
||||
|
||||
val result = sut.calculate(
|
||||
userWallet = fromStatus.userWallet,
|
||||
fromSwapCurrencyStatus = fromStatus,
|
||||
amount = BigDecimal("3.0"),
|
||||
selectedFeeToken = coinStatus,
|
||||
)
|
||||
|
||||
assertThat(result.isRight()).isTrue()
|
||||
result.onRight { cexResult ->
|
||||
val loaded = cexResult.transactionFee as TransactionFeeResult.Loaded
|
||||
val patched = (loaded.fee as TransactionFee.Single).normal as Fee.Ethereum.Legacy
|
||||
// 100_000 * 105 / 100 = 105_000
|
||||
assertThat(patched.gasLimit).isEqualTo(BigInteger.valueOf(105_000))
|
||||
// 105_000 * 20_000_000_000 / 1e18 = 0.0000021
|
||||
assertThat(patched.amount.value).isEquivalentAccordingToCompareTo(BigDecimal("0.0000021"))
|
||||
}
|
||||
coVerify(exactly = 1) {
|
||||
estimateFeeUseCase.invoke(
|
||||
amount = BigDecimal("3.0"),
|
||||
userWallet = fromStatus.userWallet,
|
||||
cryptoCurrencyStatus = fromStatus.status,
|
||||
)
|
||||
}
|
||||
coVerify(exactly = 0) {
|
||||
estimateFeeForTokenUseCase.invoke(any(), any(), any(), any())
|
||||
estimateFeeForGaslessTxUseCase.invoke(any(), any(), any())
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `GIVEN explicit native selectedFeeToken with non-Ethereum fee WHEN calculate THEN bump is a no-op`() =
|
||||
runTest {
|
||||
val fromStatus = buildSwapCurrencyStatus(networkRawId = ethNetwork)
|
||||
val coinCurrency = mockk<CryptoCurrency.Coin>(relaxed = true)
|
||||
val coinStatus = mockk<CryptoCurrencyStatus>(relaxed = true) {
|
||||
every { currency } returns coinCurrency
|
||||
}
|
||||
val rawFee = Fee.Common(
|
||||
amount = Amount(currencySymbol = "BTC", value = BigDecimal("0.0001"), decimals = 8),
|
||||
)
|
||||
coEvery {
|
||||
estimateFeeUseCase(any(), any(), any())
|
||||
} returns TransactionFee.Single(normal = rawFee).right()
|
||||
|
||||
val result = sut.calculate(
|
||||
userWallet = fromStatus.userWallet,
|
||||
fromSwapCurrencyStatus = fromStatus,
|
||||
amount = BigDecimal("1.0"),
|
||||
selectedFeeToken = coinStatus,
|
||||
)
|
||||
|
||||
result.onRight { cexResult ->
|
||||
val loaded = cexResult.transactionFee as TransactionFeeResult.Loaded
|
||||
val unchanged = (loaded.fee as TransactionFee.Single).normal as Fee.Common
|
||||
assertThat(unchanged).isSameInstanceAs(rawFee)
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `GIVEN native path returns Left WHEN calculate THEN error is propagated`() = runTest {
|
||||
val fromStatus = buildSwapCurrencyStatus(networkRawId = ethNetwork)
|
||||
val coinCurrency = mockk<CryptoCurrency.Coin>(relaxed = true)
|
||||
val coinStatus = mockk<CryptoCurrencyStatus>(relaxed = true) {
|
||||
every { currency } returns coinCurrency
|
||||
}
|
||||
coEvery {
|
||||
estimateFeeUseCase(any(), any(), any())
|
||||
} returns GetFeeError.UnknownError.left()
|
||||
|
||||
val result = sut.calculate(
|
||||
userWallet = fromStatus.userWallet,
|
||||
fromSwapCurrencyStatus = fromStatus,
|
||||
amount = BigDecimal("1.0"),
|
||||
selectedFeeToken = coinStatus,
|
||||
)
|
||||
|
||||
assertThat(result.isLeft()).isTrue()
|
||||
result.onLeft { assertThat(it).isInstanceOf(GetFeeError.UnknownError::class.java) }
|
||||
}
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// Choosable Ethereum fee (multiple legs) — bump applied to every leg
|
||||
// -------------------------------------------------------------------------
|
||||
|
||||
@Test
|
||||
fun `GIVEN explicit native with Choosable Ethereum fee WHEN calculate THEN bump applied to all three legs`() =
|
||||
runTest {
|
||||
val fromStatus = buildSwapCurrencyStatus(networkRawId = ethNetwork)
|
||||
val coinCurrency = mockk<CryptoCurrency.Coin>(relaxed = true)
|
||||
val coinStatus = mockk<CryptoCurrencyStatus>(relaxed = true) {
|
||||
every { currency } returns coinCurrency
|
||||
}
|
||||
val gasPrice = BigInteger.valueOf(10_000_000_000)
|
||||
val rawFee = TransactionFee.Choosable(
|
||||
minimum = Fee.Ethereum.Legacy(
|
||||
amount = Amount(currencySymbol = "ETH", value = BigDecimal("0.000001"), decimals = 18),
|
||||
gasLimit = BigInteger.valueOf(50_000),
|
||||
gasPrice = gasPrice,
|
||||
),
|
||||
normal = Fee.Ethereum.Legacy(
|
||||
amount = Amount(currencySymbol = "ETH", value = BigDecimal("0.000002"), decimals = 18),
|
||||
gasLimit = BigInteger.valueOf(100_000),
|
||||
gasPrice = gasPrice,
|
||||
),
|
||||
priority = Fee.Ethereum.Legacy(
|
||||
amount = Amount(currencySymbol = "ETH", value = BigDecimal("0.000003"), decimals = 18),
|
||||
gasLimit = BigInteger.valueOf(150_000),
|
||||
gasPrice = gasPrice,
|
||||
),
|
||||
)
|
||||
coEvery {
|
||||
estimateFeeUseCase(any(), any(), any())
|
||||
} returns rawFee.right()
|
||||
|
||||
val result = sut.calculate(
|
||||
userWallet = fromStatus.userWallet,
|
||||
fromSwapCurrencyStatus = fromStatus,
|
||||
amount = BigDecimal("1.0"),
|
||||
selectedFeeToken = coinStatus,
|
||||
)
|
||||
|
||||
result.onRight { cexResult ->
|
||||
val loaded = cexResult.transactionFee as TransactionFeeResult.Loaded
|
||||
val patched = loaded.fee as TransactionFee.Choosable
|
||||
assertThat((patched.minimum as Fee.Ethereum.Legacy).gasLimit)
|
||||
.isEqualTo(BigInteger.valueOf(52_500))
|
||||
assertThat((patched.normal as Fee.Ethereum.Legacy).gasLimit)
|
||||
.isEqualTo(BigInteger.valueOf(105_000))
|
||||
assertThat((patched.priority as Fee.Ethereum.Legacy).gasLimit)
|
||||
.isEqualTo(BigInteger.valueOf(157_500))
|
||||
}
|
||||
}
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// userWallet propagation
|
||||
// -------------------------------------------------------------------------
|
||||
|
||||
@Test
|
||||
fun `GIVEN gasless path WHEN calculate THEN userWallet is propagated to estimateFeeForGaslessTxUseCase`() =
|
||||
runTest {
|
||||
val fromStatus = buildSwapCurrencyStatus(networkRawId = ethNetwork)
|
||||
val customWallet = mockk<UserWallet>(relaxed = true)
|
||||
val expected = mockk<TransactionFeeExtended>(relaxed = true)
|
||||
coEvery {
|
||||
estimateFeeForGaslessTxUseCase(any(), any(), any())
|
||||
} returns expected.right()
|
||||
|
||||
sut.calculate(
|
||||
userWallet = customWallet,
|
||||
fromSwapCurrencyStatus = fromStatus,
|
||||
amount = BigDecimal("1.0"),
|
||||
selectedFeeToken = null,
|
||||
)
|
||||
|
||||
coVerify(exactly = 1) {
|
||||
estimateFeeForGaslessTxUseCase.invoke(
|
||||
amount = BigDecimal("1.0"),
|
||||
userWallet = customWallet,
|
||||
sendingTokenCurrencyStatus = fromStatus.status,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,442 @@
|
|||
package com.tangem.feature.swap.domain.fee
|
||||
|
||||
import android.util.Base64
|
||||
import arrow.core.left
|
||||
import arrow.core.right
|
||||
import com.google.common.truth.Truth.assertThat
|
||||
import com.tangem.blockchain.blockchains.solana.SolanaTransactionHelper
|
||||
import com.tangem.blockchain.common.Amount
|
||||
import com.tangem.blockchain.common.Blockchain
|
||||
import com.tangem.blockchain.common.TransactionData
|
||||
import com.tangem.blockchain.common.TransactionExtras
|
||||
import com.tangem.blockchain.common.transaction.Fee
|
||||
import com.tangem.blockchain.common.transaction.TransactionFee
|
||||
import com.tangem.blockchainsdk.utils.toNetworkId
|
||||
import com.tangem.domain.models.wallet.UserWallet
|
||||
import com.tangem.domain.swap.models.SwapCurrencyStatus
|
||||
import com.tangem.domain.transaction.error.GetFeeError
|
||||
import com.tangem.domain.transaction.usecase.CreateTransactionDataExtrasUseCase
|
||||
import com.tangem.domain.transaction.usecase.GetEthSpecificFeeUseCase
|
||||
import com.tangem.domain.transaction.usecase.GetFeeUseCase
|
||||
import com.tangem.domain.transaction.usecase.gasless.GetFeeForTokenUseCase
|
||||
import com.tangem.domain.walletmanager.WalletManagersFacade
|
||||
import com.tangem.feature.swap.domain.buildSwapCurrencyStatus
|
||||
import com.tangem.feature.swap.domain.models.ExpressDataError
|
||||
import com.tangem.feature.swap.domain.models.SwapAmount
|
||||
import com.tangem.feature.swap.domain.models.domain.ExpressTransactionModel
|
||||
import io.mockk.clearAllMocks
|
||||
import io.mockk.coEvery
|
||||
import io.mockk.coVerify
|
||||
import io.mockk.every
|
||||
import io.mockk.mockk
|
||||
import io.mockk.mockkObject
|
||||
import io.mockk.mockkStatic
|
||||
import io.mockk.slot
|
||||
import io.mockk.unmockkAll
|
||||
import kotlinx.coroutines.test.runTest
|
||||
import org.junit.jupiter.api.AfterEach
|
||||
import org.junit.jupiter.api.BeforeEach
|
||||
import org.junit.jupiter.api.Test
|
||||
import org.junit.jupiter.api.TestInstance
|
||||
import java.math.BigDecimal
|
||||
import java.math.BigInteger
|
||||
|
||||
/**
|
||||
* Unit tests for [DexSwapFeeCalculator] ([REDACTED_TASK_KEY] — Phase 2).
|
||||
*
|
||||
* Mirrors the cases from `SwapInteractorImplLoadFeeForDexTest` and
|
||||
* `SwapInteractorImplOtherNativeFeeTest` but exercises the calculator directly with a
|
||||
* minimal set of mocks instead of going through the public `findBestQuote` entry point.
|
||||
*/
|
||||
@TestInstance(TestInstance.Lifecycle.PER_CLASS)
|
||||
internal class DexSwapFeeCalculatorTest {
|
||||
|
||||
private val ethNetwork = Blockchain.Ethereum.toNetworkId()
|
||||
private val solanaNetwork = Blockchain.Solana.toNetworkId()
|
||||
|
||||
private val getFeeUseCase: GetFeeUseCase = mockk(relaxed = true)
|
||||
private val getEthSpecificFeeUseCase: GetEthSpecificFeeUseCase = mockk(relaxed = true)
|
||||
private val getFeeForTokenUseCase: GetFeeForTokenUseCase = mockk(relaxed = true)
|
||||
private val createTransactionExtrasUseCase: CreateTransactionDataExtrasUseCase = mockk(relaxed = true)
|
||||
private val walletManagersFacade: WalletManagersFacade = mockk(relaxed = true)
|
||||
|
||||
private val dexBump = PatchEthGasLimitForSwap(percentage = PatchEthGasLimitForSwap.DEX_PERCENTAGE)
|
||||
|
||||
private val sut: DexSwapFeeCalculator by lazy {
|
||||
DexSwapFeeCalculator(
|
||||
getFeeUseCase = getFeeUseCase,
|
||||
getEthSpecificFeeUseCase = getEthSpecificFeeUseCase,
|
||||
getFeeForTokenUseCase = getFeeForTokenUseCase,
|
||||
createTransactionExtrasUseCase = createTransactionExtrasUseCase,
|
||||
walletManagersFacade = walletManagersFacade,
|
||||
patchEthGasLimitForSwap = dexBump,
|
||||
)
|
||||
}
|
||||
|
||||
@BeforeEach
|
||||
fun setup() {
|
||||
// Default: native balance is plenty.
|
||||
coEvery { walletManagersFacade.getNativeTokenBalance(any(), any(), any()) } returns BigDecimal("10")
|
||||
every { createTransactionExtrasUseCase.invoke(data = any(), network = any()) } returns
|
||||
mockk<TransactionExtras>(relaxed = true).right()
|
||||
}
|
||||
|
||||
@AfterEach
|
||||
fun tearDown() {
|
||||
clearAllMocks()
|
||||
unmockkAll()
|
||||
}
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// EVM happy path
|
||||
// -------------------------------------------------------------------------
|
||||
|
||||
@Test
|
||||
fun `EVM DEX swap propagates extras destinationAddress sourceAddress and amount to getFeeUseCase`() = runTest {
|
||||
val fromStatus = buildSwapCurrencyStatus(networkRawId = ethNetwork, isCoin = true)
|
||||
val transaction = buildDex(
|
||||
txValue = "1000000000000000", // 0.001 ETH
|
||||
txTo = "0xRecipient",
|
||||
txFrom = "0xSender",
|
||||
txData = "0xPayload",
|
||||
)
|
||||
val capturedTxData = slot<TransactionData>()
|
||||
coEvery {
|
||||
getFeeUseCase.invoke(
|
||||
userWallet = any(),
|
||||
network = any(),
|
||||
transactionData = capture(capturedTxData),
|
||||
)
|
||||
} returns mockk<TransactionFee.Single>(relaxed = true).right()
|
||||
|
||||
sut.calculate(fromStatus, transaction)
|
||||
|
||||
assertThat(capturedTxData.isCaptured).isTrue()
|
||||
val uncompiled = capturedTxData.captured as TransactionData.Uncompiled
|
||||
assertThat(uncompiled.destinationAddress).isEqualTo("0xRecipient")
|
||||
assertThat(uncompiled.sourceAddress).isEqualTo("0xSender")
|
||||
// amount.value is the txValue moved-point-left by native decimals (18 for ETH) → 0.001
|
||||
assertThat(uncompiled.amount.value).isEquivalentAccordingToCompareTo(BigDecimal("0.001"))
|
||||
// extras came from createTransactionExtrasUseCase
|
||||
assertThat(uncompiled.extras).isNotNull()
|
||||
}
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// EVM zero-balance short-circuit
|
||||
// -------------------------------------------------------------------------
|
||||
|
||||
@Test
|
||||
fun `EVM DEX swap with native balance ZERO returns Left UnknownError`() = runTest {
|
||||
val fromStatus = buildSwapCurrencyStatus(networkRawId = ethNetwork, isCoin = true)
|
||||
val transaction = buildDex(txValue = "0")
|
||||
coEvery { walletManagersFacade.getNativeTokenBalance(any(), any(), any()) } returns BigDecimal.ZERO
|
||||
|
||||
val result = sut.calculate(fromStatus, transaction)
|
||||
|
||||
assertThat(result.isLeft()).isTrue()
|
||||
result.onLeft { assertThat(it).isEqualTo(ExpressDataError.UnknownError()) }
|
||||
// getFeeUseCase should not have been called because balance check short-circuits first.
|
||||
// Use a more permissive verify to avoid clashing with the other overload signatures.
|
||||
coVerify(exactly = 0) {
|
||||
getFeeUseCase.invoke(
|
||||
userWallet = any(),
|
||||
network = any(),
|
||||
transactionData = any<TransactionData>(),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// EVM IllegalStateException → fallback to GetEthSpecificFeeUseCase
|
||||
// -------------------------------------------------------------------------
|
||||
|
||||
@Test
|
||||
fun `EVM DEX swap falls back to getEthSpecificFeeUseCase when txValue is null`() = runTest {
|
||||
val fromStatus = buildSwapCurrencyStatus(networkRawId = ethNetwork, isCoin = true)
|
||||
val gas = BigInteger.valueOf(150_000L)
|
||||
val transaction = buildDex(txValue = null, gas = gas)
|
||||
|
||||
coEvery {
|
||||
getEthSpecificFeeUseCase.invoke(
|
||||
userWallet = any(),
|
||||
cryptoCurrency = any(),
|
||||
gasLimit = any(),
|
||||
gasPrice = any(),
|
||||
)
|
||||
} returns mockk<TransactionFee.Choosable>(relaxed = true).right()
|
||||
|
||||
sut.calculate(fromStatus, transaction)
|
||||
|
||||
coVerify(exactly = 1) {
|
||||
getEthSpecificFeeUseCase.invoke(
|
||||
userWallet = any(),
|
||||
cryptoCurrency = any(),
|
||||
gasLimit = gas,
|
||||
gasPrice = any(),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `EVM DEX swap falls back to getEthSpecificFeeUseCase when createTransactionExtrasUseCase fails`() = runTest {
|
||||
val fromStatus = buildSwapCurrencyStatus(networkRawId = ethNetwork, isCoin = true)
|
||||
val gas = BigInteger.valueOf(75_000L)
|
||||
val transaction = buildDex(txValue = "1000000000000000", gas = gas)
|
||||
|
||||
every {
|
||||
createTransactionExtrasUseCase.invoke(data = any(), network = any())
|
||||
} returns IllegalStateException("forced fail").left()
|
||||
|
||||
coEvery {
|
||||
getEthSpecificFeeUseCase.invoke(
|
||||
userWallet = any(),
|
||||
cryptoCurrency = any(),
|
||||
gasLimit = any(),
|
||||
gasPrice = any(),
|
||||
)
|
||||
} returns mockk<TransactionFee.Choosable>(relaxed = true).right()
|
||||
|
||||
sut.calculate(fromStatus, transaction)
|
||||
|
||||
coVerify(exactly = 1) {
|
||||
getEthSpecificFeeUseCase.invoke(
|
||||
userWallet = any(),
|
||||
cryptoCurrency = any(),
|
||||
gasLimit = gas,
|
||||
gasPrice = any(),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `EVM DEX swap falls back to getEthSpecificFeeUseCase when getFeeUseCase returns Left`() = runTest {
|
||||
val fromStatus = buildSwapCurrencyStatus(networkRawId = ethNetwork, isCoin = true)
|
||||
val gas = BigInteger.valueOf(50_000L)
|
||||
val transaction = buildDex(txValue = "1000000000000000", gas = gas)
|
||||
|
||||
coEvery {
|
||||
getFeeUseCase.invoke(userWallet = any(), network = any(), transactionData = any())
|
||||
} returns GetFeeError.UnknownError.left()
|
||||
|
||||
coEvery {
|
||||
getEthSpecificFeeUseCase.invoke(
|
||||
userWallet = any(),
|
||||
cryptoCurrency = any(),
|
||||
gasLimit = any(),
|
||||
gasPrice = any(),
|
||||
)
|
||||
} returns mockk<TransactionFee.Choosable>(relaxed = true).right()
|
||||
|
||||
sut.calculate(fromStatus, transaction)
|
||||
|
||||
coVerify(exactly = 1) {
|
||||
getEthSpecificFeeUseCase.invoke(
|
||||
userWallet = any(),
|
||||
cryptoCurrency = any(),
|
||||
gasLimit = gas,
|
||||
gasPrice = any(),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// 12% gas patch — golden numbers
|
||||
// -------------------------------------------------------------------------
|
||||
|
||||
@Test
|
||||
fun `EVM DEX swap applies 12 percent gas-limit bump on Ethereum Legacy fee`() = runTest {
|
||||
val fromStatus = buildSwapCurrencyStatus(networkRawId = ethNetwork, isCoin = true)
|
||||
val transaction = buildDex(txValue = "1000000000000000")
|
||||
|
||||
// amount = 100_000 * 20e9 / 1e18 = 0.000002 ETH (decimals = 18)
|
||||
val rawFee = Fee.Ethereum.Legacy(
|
||||
amount = Amount(currencySymbol = "ETH", value = BigDecimal("0.000002"), decimals = 18),
|
||||
gasLimit = BigInteger.valueOf(100_000),
|
||||
gasPrice = BigInteger.valueOf(20_000_000_000),
|
||||
)
|
||||
coEvery {
|
||||
getFeeUseCase.invoke(userWallet = any(), network = any(), transactionData = any())
|
||||
} returns TransactionFee.Single(normal = rawFee).right()
|
||||
|
||||
val result = sut.calculate(fromStatus, transaction)
|
||||
|
||||
assertThat(result.isRight()).isTrue()
|
||||
result.onRight { dexFeeResult ->
|
||||
val patched = (dexFeeResult.transactionFee as TransactionFeeResult.Loaded).fee
|
||||
val patchedFee = (patched as TransactionFee.Single).normal as Fee.Ethereum.Legacy
|
||||
// 100_000 * 112 / 100 = 112_000
|
||||
assertThat(patchedFee.gasLimit).isEqualTo(BigInteger.valueOf(112_000))
|
||||
// 112_000 * 20_000_000_000 / 1e18 = 0.00000224
|
||||
assertThat(patchedFee.amount.value).isEquivalentAccordingToCompareTo(BigDecimal("0.00000224"))
|
||||
// Gas is propagated for downstream consumers
|
||||
assertThat(dexFeeResult.gas).isEqualTo(transaction.gas)
|
||||
}
|
||||
}
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// Solana DEX path
|
||||
// -------------------------------------------------------------------------
|
||||
|
||||
@Test
|
||||
fun `Solana DEX uses TransactionData Compiled and skips the 12 percent gas patch`() = runTest {
|
||||
mockkStatic(Base64::class)
|
||||
every { Base64.decode(any<String>(), any()) } returns ByteArray(64)
|
||||
mockkObject(SolanaTransactionHelper)
|
||||
every { SolanaTransactionHelper.removeSignaturesPlaceholders(any()) } returns ByteArray(64)
|
||||
|
||||
val fromStatus = buildSwapCurrencyStatus(networkRawId = solanaNetwork, isCoin = true)
|
||||
val transaction = buildDex(txData = "U29sYW5h")
|
||||
|
||||
val rawFeeAmount = BigDecimal("0.005000")
|
||||
val rawFee: Fee = Fee.Common(
|
||||
amount = Amount(currencySymbol = "SOL", value = rawFeeAmount, decimals = 9),
|
||||
)
|
||||
val txFee = TransactionFee.Single(normal = rawFee)
|
||||
val capturedTxData = slot<TransactionData>()
|
||||
coEvery {
|
||||
getFeeUseCase.invoke(
|
||||
userWallet = any(),
|
||||
network = any(),
|
||||
transactionData = capture(capturedTxData),
|
||||
)
|
||||
} returns txFee.right()
|
||||
|
||||
val result = sut.calculate(fromStatus, transaction)
|
||||
|
||||
assertThat(capturedTxData.isCaptured).isTrue()
|
||||
assertThat(capturedTxData.captured).isInstanceOf(TransactionData.Compiled::class.java)
|
||||
result.onRight { dexFeeResult ->
|
||||
// No bump: Fee.Common is non-Ethereum even on the EVM path; on Solana the bump isn't
|
||||
// applied at all. The raw value is preserved.
|
||||
val patched = (dexFeeResult.transactionFee as TransactionFeeResult.Loaded).fee
|
||||
val solFee = (patched as TransactionFee.Single).normal as Fee.Common
|
||||
assertThat(solFee.amount.value).isEquivalentAccordingToCompareTo(rawFeeAmount)
|
||||
// Solana path leaves gas null (caller doesn't need it).
|
||||
assertThat(dexFeeResult.gas).isNull()
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `Solana DEX size guard returns Left TooLargeSolanaTransactionError on Cold wallet`() = runTest {
|
||||
mockkStatic(Base64::class)
|
||||
val oversizedBytes = ByteArray(1300)
|
||||
every { Base64.decode(any<String>(), any()) } returns oversizedBytes
|
||||
mockkObject(SolanaTransactionHelper)
|
||||
every { SolanaTransactionHelper.removeSignaturesPlaceholders(any()) } returns oversizedBytes
|
||||
|
||||
val coldWallet = mockk<UserWallet.Cold>(relaxed = true)
|
||||
val baseStatus = buildSwapCurrencyStatus(networkRawId = solanaNetwork, isCoin = true)
|
||||
val fromStatus = SwapCurrencyStatus(
|
||||
userWallet = coldWallet,
|
||||
status = baseStatus.status,
|
||||
account = baseStatus.account,
|
||||
)
|
||||
val transaction = buildDex(txData = "very-long-base64-content==")
|
||||
|
||||
val result = sut.calculate(fromStatus, transaction)
|
||||
|
||||
assertThat(result.isLeft()).isTrue()
|
||||
result.onLeft { error ->
|
||||
assertThat(error).isEqualTo(ExpressDataError.TooLargeSolanaTransactionError())
|
||||
}
|
||||
// No fee is computed when the size guard trips
|
||||
coVerify(exactly = 0) {
|
||||
getFeeUseCase.invoke(userWallet = any(), network = any(), transactionData = any())
|
||||
}
|
||||
}
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// otherNativeFee propagation (bridge protocol fee)
|
||||
// -------------------------------------------------------------------------
|
||||
|
||||
@Test
|
||||
fun `EVM DEX swap propagates otherNativeFee with native decimals when otherNativeFeeWei is set`() = runTest {
|
||||
val fromStatus = buildSwapCurrencyStatus(networkRawId = ethNetwork, isCoin = true)
|
||||
// 0.5 ETH expressed in wei (1e18)
|
||||
val transaction = buildDex(
|
||||
txValue = "1000000000000000",
|
||||
otherNativeFeeWei = BigDecimal("500000000000000000"),
|
||||
)
|
||||
coEvery {
|
||||
getFeeUseCase.invoke(userWallet = any(), network = any(), transactionData = any())
|
||||
} returns TransactionFee.Single(normal = ethLegacyFee()).right()
|
||||
|
||||
val result = sut.calculate(fromStatus, transaction)
|
||||
|
||||
result.onRight { dexFeeResult ->
|
||||
assertThat(dexFeeResult.otherNativeFee).isEquivalentAccordingToCompareTo(BigDecimal("0.5"))
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `EVM DEX swap returns ZERO otherNativeFee when otherNativeFeeWei is null`() = runTest {
|
||||
val fromStatus = buildSwapCurrencyStatus(networkRawId = ethNetwork, isCoin = true)
|
||||
val transaction = buildDex(txValue = "1000000000000000", otherNativeFeeWei = null)
|
||||
coEvery {
|
||||
getFeeUseCase.invoke(userWallet = any(), network = any(), transactionData = any())
|
||||
} returns TransactionFee.Single(normal = ethLegacyFee()).right()
|
||||
|
||||
val result = sut.calculate(fromStatus, transaction)
|
||||
|
||||
result.onRight { dexFeeResult ->
|
||||
assertThat(dexFeeResult.otherNativeFee).isEqualTo(BigDecimal.ZERO)
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `Solana DEX swap propagates otherNativeFee using native decimals`() = runTest {
|
||||
mockkStatic(Base64::class)
|
||||
every { Base64.decode(any<String>(), any()) } returns ByteArray(64)
|
||||
mockkObject(SolanaTransactionHelper)
|
||||
every { SolanaTransactionHelper.removeSignaturesPlaceholders(any()) } returns ByteArray(64)
|
||||
|
||||
val fromStatus = buildSwapCurrencyStatus(networkRawId = solanaNetwork, isCoin = true, decimals = 9)
|
||||
// 1.5 SOL expressed with 9 decimals = 1_500_000_000
|
||||
val transaction = buildDex(
|
||||
txData = "U29sYW5h",
|
||||
otherNativeFeeWei = BigDecimal("1500000000"),
|
||||
)
|
||||
coEvery {
|
||||
getFeeUseCase.invoke(userWallet = any(), network = any(), transactionData = any())
|
||||
} returns TransactionFee.Single(
|
||||
normal = Fee.Common(Amount(currencySymbol = "SOL", value = BigDecimal("0.005"), decimals = 9)),
|
||||
).right()
|
||||
|
||||
val result = sut.calculate(fromStatus, transaction)
|
||||
|
||||
result.onRight { dexFeeResult ->
|
||||
assertThat(dexFeeResult.otherNativeFee).isEquivalentAccordingToCompareTo(BigDecimal("1.5"))
|
||||
}
|
||||
}
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// Helpers
|
||||
// -------------------------------------------------------------------------
|
||||
|
||||
private fun ethLegacyFee(): Fee.Ethereum.Legacy = Fee.Ethereum.Legacy(
|
||||
amount = Amount(currencySymbol = "ETH", value = BigDecimal("0.000002"), decimals = 18),
|
||||
gasLimit = BigInteger.valueOf(100_000),
|
||||
gasPrice = BigInteger.valueOf(20_000_000_000),
|
||||
)
|
||||
|
||||
private fun buildDex(
|
||||
txData: String = "dGVzdA==",
|
||||
txValue: String? = "0",
|
||||
toAmount: BigDecimal = BigDecimal("0.5"),
|
||||
otherNativeFeeWei: BigDecimal? = null,
|
||||
gas: BigInteger = BigInteger.valueOf(21_000L),
|
||||
txTo: String = "0xRecipient",
|
||||
txFrom: String = "0xSender",
|
||||
): ExpressTransactionModel.DEX = ExpressTransactionModel.DEX(
|
||||
fromAmount = SwapAmount(BigDecimal.ONE, 18),
|
||||
toAmount = SwapAmount(toAmount, 18),
|
||||
txValue = txValue,
|
||||
txId = "tx-id-123",
|
||||
txTo = txTo,
|
||||
txExtraId = null,
|
||||
txFrom = txFrom,
|
||||
txData = txData,
|
||||
otherNativeFeeWei = otherNativeFeeWei,
|
||||
gas = gas,
|
||||
)
|
||||
}
|
||||
|
|
@ -0,0 +1,298 @@
|
|||
package com.tangem.feature.swap.domain.fee
|
||||
|
||||
import com.google.common.truth.Truth.assertThat
|
||||
import com.tangem.blockchain.common.Amount
|
||||
import com.tangem.blockchain.common.transaction.Fee
|
||||
import com.tangem.blockchain.common.transaction.TransactionFee
|
||||
import org.junit.jupiter.api.Test
|
||||
import org.junit.jupiter.api.TestInstance
|
||||
import org.junit.jupiter.api.assertThrows
|
||||
import java.math.BigDecimal
|
||||
import java.math.BigInteger
|
||||
|
||||
/**
|
||||
* Pure-JVM unit tests for [com.tangem.feature.swap.domain.fee.PatchEthGasLimitForSwap] ([REDACTED_TASK_KEY]).
|
||||
*
|
||||
* Pinned behavior — these tests guard the gas-bump arithmetic against accidental drift in:
|
||||
* - Ethereum [com.tangem.blockchain.common.transaction.Fee.Ethereum.Legacy] / [com.tangem.blockchain.common.transaction.Fee.Ethereum.EIP1559]: gasLimit *= percentage / 100,
|
||||
* amount = (newGasLimit * gasPrice) shifted left by amount decimals, decimals preserved.
|
||||
* - [com.tangem.blockchain.common.transaction.Fee.Ethereum.TokenCurrency]: throws (current `error("handle in [REDACTED_TASK_KEY]")`).
|
||||
* - All non-Ethereum [com.tangem.blockchain.common.transaction.Fee] subtypes: returned unchanged.
|
||||
* - [com.tangem.blockchain.common.transaction.TransactionFee.Choosable]: applies the bump to all three legs (minimum/normal/priority).
|
||||
* - [com.tangem.blockchain.common.transaction.TransactionFee.Single]: applies the bump to `normal`.
|
||||
*/
|
||||
@TestInstance(TestInstance.Lifecycle.PER_CLASS)
|
||||
internal class PatchEthGasLimitForSwapTest {
|
||||
|
||||
private val dexBump = PatchEthGasLimitForSwap(percentage = PatchEthGasLimitForSwap.DEX_PERCENTAGE)
|
||||
private val sendBump = PatchEthGasLimitForSwap(percentage = PatchEthGasLimitForSwap.SEND_PERCENTAGE)
|
||||
|
||||
// region Ethereum.Legacy
|
||||
|
||||
@Test
|
||||
fun `GIVEN Ethereum Legacy fee WHEN dex bump applied THEN gasLimit is multiplied by 112 percent`() {
|
||||
// amount = gasLimit * gasPrice shifted left by 18 → 100000 * 20_000_000_000 / 1e18 = 0.000002 ETH
|
||||
val gasLimit = BigInteger.valueOf(100_000)
|
||||
val gasPrice = BigInteger.valueOf(20_000_000_000) // 20 gwei
|
||||
val amountValue = BigDecimal("0.000002") // 100_000 * 20e9 / 1e18
|
||||
val initialFee = Fee.Ethereum.Legacy(
|
||||
amount = ethAmount(amountValue, decimals = 18),
|
||||
gasLimit = gasLimit,
|
||||
gasPrice = gasPrice,
|
||||
)
|
||||
|
||||
val result = dexBump(TransactionFee.Single(normal = initialFee))
|
||||
|
||||
val patched = (result as TransactionFee.Single).normal as Fee.Ethereum.Legacy
|
||||
// 100_000 * 112 / 100 = 112_000
|
||||
assertThat(patched.gasLimit).isEqualTo(BigInteger.valueOf(112_000))
|
||||
// amount = 112_000 * 20_000_000_000 / 1e18 = 0.00000224
|
||||
assertThat(patched.amount.value).isEquivalentAccordingToCompareTo(BigDecimal("0.00000224"))
|
||||
// amount decimals must be preserved
|
||||
assertThat(patched.amount.decimals).isEqualTo(18)
|
||||
// gasPrice unchanged
|
||||
assertThat(patched.gasPrice).isEqualTo(gasPrice)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `GIVEN Ethereum Legacy fee WHEN send bump applied THEN gasLimit is multiplied by 105 percent`() {
|
||||
val gasLimit = BigInteger.valueOf(100_000)
|
||||
val gasPrice = BigInteger.valueOf(20_000_000_000)
|
||||
val initialFee = Fee.Ethereum.Legacy(
|
||||
amount = ethAmount(BigDecimal("0.000002"), decimals = 18),
|
||||
gasLimit = gasLimit,
|
||||
gasPrice = gasPrice,
|
||||
)
|
||||
|
||||
val result = sendBump(TransactionFee.Single(normal = initialFee))
|
||||
|
||||
val patched = (result as TransactionFee.Single).normal as Fee.Ethereum.Legacy
|
||||
// 100_000 * 105 / 100 = 105_000
|
||||
assertThat(patched.gasLimit).isEqualTo(BigInteger.valueOf(105_000))
|
||||
// amount = 105_000 * 20_000_000_000 / 1e18 = 0.0000021
|
||||
assertThat(patched.amount.value).isEquivalentAccordingToCompareTo(BigDecimal("0.0000021"))
|
||||
assertThat(patched.amount.decimals).isEqualTo(18)
|
||||
assertThat(patched.gasPrice).isEqualTo(gasPrice)
|
||||
}
|
||||
|
||||
// endregion
|
||||
|
||||
// region Ethereum.EIP1559
|
||||
|
||||
@Test
|
||||
fun `GIVEN Ethereum EIP1559 fee WHEN dex bump applied THEN gasLimit and amount are bumped`() {
|
||||
val gasLimit = BigInteger.valueOf(50_000)
|
||||
// Pretend gasPrice (effective) is 30 gwei → amount = 50_000 * 30e9 / 1e18 = 0.0000015
|
||||
val initialFee = Fee.Ethereum.EIP1559(
|
||||
amount = ethAmount(BigDecimal("0.0000015"), decimals = 18),
|
||||
gasLimit = gasLimit,
|
||||
maxFeePerGas = BigInteger.valueOf(40_000_000_000),
|
||||
priorityFee = BigInteger.valueOf(2_000_000_000),
|
||||
)
|
||||
|
||||
val result = dexBump(TransactionFee.Single(normal = initialFee))
|
||||
|
||||
val patched = (result as TransactionFee.Single).normal as Fee.Ethereum.EIP1559
|
||||
// 50_000 * 112 / 100 = 56_000
|
||||
assertThat(patched.gasLimit).isEqualTo(BigInteger.valueOf(56_000))
|
||||
// amount = 56_000 * 30e9 / 1e18 = 0.00000168
|
||||
assertThat(patched.amount.value).isEquivalentAccordingToCompareTo(BigDecimal("0.00000168"))
|
||||
assertThat(patched.amount.decimals).isEqualTo(18)
|
||||
// EIP1559-specific fields unchanged
|
||||
assertThat(patched.maxFeePerGas).isEqualTo(BigInteger.valueOf(40_000_000_000))
|
||||
assertThat(patched.priorityFee).isEqualTo(BigInteger.valueOf(2_000_000_000))
|
||||
}
|
||||
|
||||
// endregion
|
||||
|
||||
// region Ethereum.TokenCurrency throws
|
||||
|
||||
@Test
|
||||
fun `GIVEN Ethereum TokenCurrency fee WHEN dex bump applied THEN throws IllegalStateException`() {
|
||||
val tokenFee = Fee.Ethereum.TokenCurrency(
|
||||
amount = ethAmount(BigDecimal("0.001"), decimals = 18),
|
||||
gasLimit = BigInteger.valueOf(100_000),
|
||||
coinPriceInToken = BigInteger.ONE,
|
||||
feeTransferGasLimit = BigInteger.valueOf(50_000),
|
||||
baseGas = BigInteger.valueOf(21_000),
|
||||
)
|
||||
|
||||
assertThrows<IllegalStateException> {
|
||||
dexBump(TransactionFee.Single(normal = tokenFee))
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `GIVEN Ethereum TokenCurrency fee WHEN dex bump applied THEN error message points to [REDACTED_TASK_KEY]`() {
|
||||
val tokenFee = Fee.Ethereum.TokenCurrency(
|
||||
amount = ethAmount(BigDecimal("0.001"), decimals = 18),
|
||||
gasLimit = BigInteger.valueOf(100_000),
|
||||
coinPriceInToken = BigInteger.ONE,
|
||||
feeTransferGasLimit = BigInteger.valueOf(50_000),
|
||||
baseGas = BigInteger.valueOf(21_000),
|
||||
)
|
||||
|
||||
val thrown = runCatching { dexBump(TransactionFee.Single(normal = tokenFee)) }.exceptionOrNull()
|
||||
|
||||
assertThat(thrown).isInstanceOf(IllegalStateException::class.java)
|
||||
assertThat(thrown?.message).contains("[REDACTED_TASK_KEY]")
|
||||
}
|
||||
|
||||
// endregion
|
||||
|
||||
// region Non-Ethereum subtypes returned unchanged
|
||||
|
||||
@Test
|
||||
fun `GIVEN Common fee WHEN dex bump applied THEN fee is unchanged`() {
|
||||
val initial = Fee.Common(amount = ethAmount(BigDecimal("0.001"), decimals = 8))
|
||||
val result = dexBump(TransactionFee.Single(normal = initial))
|
||||
assertThat((result as TransactionFee.Single).normal).isSameInstanceAs(initial)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `GIVEN Bitcoin fee WHEN dex bump applied THEN fee is unchanged`() {
|
||||
val initial = Fee.Bitcoin(
|
||||
amount = ethAmount(BigDecimal("0.0001"), decimals = 8),
|
||||
satoshiPerByte = BigDecimal("10"),
|
||||
txSize = BigDecimal("250"),
|
||||
)
|
||||
val result = dexBump(TransactionFee.Single(normal = initial))
|
||||
assertThat((result as TransactionFee.Single).normal).isSameInstanceAs(initial)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `GIVEN Tron fee WHEN dex bump applied THEN fee is unchanged`() {
|
||||
val initial = Fee.Tron(
|
||||
amount = ethAmount(BigDecimal("0.5"), decimals = 6),
|
||||
remainingEnergy = 1000L,
|
||||
feeEnergy = 100L,
|
||||
)
|
||||
val result = dexBump(TransactionFee.Single(normal = initial))
|
||||
assertThat((result as TransactionFee.Single).normal).isSameInstanceAs(initial)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `GIVEN Sui fee WHEN dex bump applied THEN fee is unchanged`() {
|
||||
val initial = Fee.Sui(
|
||||
amount = ethAmount(BigDecimal("0.0001"), decimals = 9),
|
||||
gasBudget = 10_000L,
|
||||
gasPrice = 1_000L,
|
||||
)
|
||||
val result = dexBump(TransactionFee.Single(normal = initial))
|
||||
assertThat((result as TransactionFee.Single).normal).isSameInstanceAs(initial)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `GIVEN Aptos fee WHEN dex bump applied THEN fee is unchanged`() {
|
||||
val initial = Fee.Aptos(
|
||||
amount = ethAmount(BigDecimal("0.0001"), decimals = 8),
|
||||
gasUnitPrice = 100L,
|
||||
gasLimit = 10_000L,
|
||||
)
|
||||
val result = dexBump(TransactionFee.Single(normal = initial))
|
||||
assertThat((result as TransactionFee.Single).normal).isSameInstanceAs(initial)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `GIVEN Hedera fee WHEN dex bump applied THEN fee is unchanged`() {
|
||||
val initial = Fee.Hedera(
|
||||
amount = ethAmount(BigDecimal("0.001"), decimals = 8),
|
||||
additionalHBARFee = BigDecimal.ZERO,
|
||||
)
|
||||
val result = dexBump(TransactionFee.Single(normal = initial))
|
||||
assertThat((result as TransactionFee.Single).normal).isSameInstanceAs(initial)
|
||||
}
|
||||
|
||||
// endregion
|
||||
|
||||
// region TransactionFee.Choosable bumps all three legs
|
||||
|
||||
@Test
|
||||
fun `GIVEN Choosable fee with three Ethereum Legacy legs WHEN dex bump applied THEN every leg is bumped`() {
|
||||
val legacyMin = Fee.Ethereum.Legacy(
|
||||
amount = ethAmount(BigDecimal("0.000001"), decimals = 18),
|
||||
gasLimit = BigInteger.valueOf(50_000),
|
||||
gasPrice = BigInteger.valueOf(20_000_000_000),
|
||||
)
|
||||
val legacyNormal = Fee.Ethereum.Legacy(
|
||||
amount = ethAmount(BigDecimal("0.000002"), decimals = 18),
|
||||
gasLimit = BigInteger.valueOf(100_000),
|
||||
gasPrice = BigInteger.valueOf(20_000_000_000),
|
||||
)
|
||||
val legacyPriority = Fee.Ethereum.Legacy(
|
||||
amount = ethAmount(BigDecimal("0.000003"), decimals = 18),
|
||||
gasLimit = BigInteger.valueOf(150_000),
|
||||
gasPrice = BigInteger.valueOf(20_000_000_000),
|
||||
)
|
||||
|
||||
val result = dexBump(
|
||||
TransactionFee.Choosable(
|
||||
minimum = legacyMin,
|
||||
normal = legacyNormal,
|
||||
priority = legacyPriority,
|
||||
),
|
||||
) as TransactionFee.Choosable
|
||||
|
||||
assertThat((result.minimum as Fee.Ethereum.Legacy).gasLimit).isEqualTo(BigInteger.valueOf(56_000))
|
||||
assertThat((result.normal as Fee.Ethereum.Legacy).gasLimit).isEqualTo(BigInteger.valueOf(112_000))
|
||||
assertThat((result.priority as Fee.Ethereum.Legacy).gasLimit).isEqualTo(BigInteger.valueOf(168_000))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `GIVEN Choosable fee with mixed legs WHEN bump applied THEN only Ethereum legs are bumped`() {
|
||||
// Two Ethereum legs and one Common leg → only the Ethereum ones are scaled.
|
||||
val ethMin = Fee.Ethereum.Legacy(
|
||||
amount = ethAmount(BigDecimal("0.000001"), decimals = 18),
|
||||
gasLimit = BigInteger.valueOf(50_000),
|
||||
gasPrice = BigInteger.valueOf(10_000_000_000),
|
||||
)
|
||||
val commonNormal = Fee.Common(amount = ethAmount(BigDecimal("0.5"), decimals = 8))
|
||||
val ethPriority = Fee.Ethereum.Legacy(
|
||||
amount = ethAmount(BigDecimal("0.000003"), decimals = 18),
|
||||
gasLimit = BigInteger.valueOf(150_000),
|
||||
gasPrice = BigInteger.valueOf(10_000_000_000),
|
||||
)
|
||||
|
||||
val result = sendBump(
|
||||
TransactionFee.Choosable(
|
||||
minimum = ethMin,
|
||||
normal = commonNormal,
|
||||
priority = ethPriority,
|
||||
),
|
||||
) as TransactionFee.Choosable
|
||||
|
||||
assertThat((result.minimum as Fee.Ethereum.Legacy).gasLimit).isEqualTo(BigInteger.valueOf(52_500))
|
||||
assertThat(result.normal).isSameInstanceAs(commonNormal)
|
||||
assertThat((result.priority as Fee.Ethereum.Legacy).gasLimit).isEqualTo(BigInteger.valueOf(157_500))
|
||||
}
|
||||
|
||||
// endregion
|
||||
|
||||
// region Decimals preserved for non-18 decimals
|
||||
|
||||
@Test
|
||||
fun `GIVEN Ethereum Legacy fee with 9 decimals WHEN dex bump applied THEN amount decimals are preserved`() {
|
||||
val gasLimit = BigInteger.valueOf(21_000)
|
||||
val gasPrice = BigInteger.valueOf(1_000_000) // 1 gwei in 9-decimal native units
|
||||
// amount = 21_000 * 1_000_000 / 1e9 = 0.021
|
||||
val initial = Fee.Ethereum.Legacy(
|
||||
amount = ethAmount(BigDecimal("0.021"), decimals = 9),
|
||||
gasLimit = gasLimit,
|
||||
gasPrice = gasPrice,
|
||||
)
|
||||
|
||||
val result = dexBump(TransactionFee.Single(normal = initial))
|
||||
|
||||
val patched = (result as TransactionFee.Single).normal as Fee.Ethereum.Legacy
|
||||
assertThat(patched.amount.decimals).isEqualTo(9)
|
||||
assertThat(patched.gasLimit).isEqualTo(BigInteger.valueOf(23_520)) // 21_000 * 112 / 100
|
||||
}
|
||||
|
||||
// endregion
|
||||
|
||||
private fun ethAmount(value: BigDecimal, decimals: Int): Amount = Amount(
|
||||
currencySymbol = "ETH",
|
||||
value = value,
|
||||
decimals = decimals,
|
||||
)
|
||||
}
|
||||
|
|
@ -0,0 +1,284 @@
|
|||
package com.tangem.feature.swap.domain.fee
|
||||
|
||||
import com.google.common.truth.Truth.assertThat
|
||||
import com.tangem.blockchain.common.Amount
|
||||
import com.tangem.blockchain.common.transaction.Fee
|
||||
import com.tangem.blockchain.common.transaction.TransactionFee
|
||||
import com.tangem.domain.models.currency.CryptoCurrencyStatus
|
||||
import com.tangem.domain.transaction.models.TransactionFeeExtended
|
||||
import com.tangem.feature.swap.domain.models.ui.FeeBucket
|
||||
import io.mockk.mockk
|
||||
import org.junit.jupiter.api.Test
|
||||
import org.junit.jupiter.api.TestInstance
|
||||
import java.math.BigDecimal
|
||||
import java.math.BigInteger
|
||||
|
||||
/**
|
||||
* Unit tests for [SwapFeeFactory] ([REDACTED_TASK_KEY] — Phase 3).
|
||||
*
|
||||
* Verifies the bucket → [com.tangem.blockchain.common.transaction.Fee] mapping rules used by
|
||||
* `SwapInteractorImpl.loadSwapFee` to assemble a `SwapFee` from a raw `TransactionFeeResult`.
|
||||
*
|
||||
* Golden mapping table — must match `FeeItemConverter` in send-v2:
|
||||
*
|
||||
* | TransactionFee shape | FeeBucket | Selected Fee |
|
||||
* |------------------------|--------------|-----------------------------------------|
|
||||
* | Single(normal) | MARKET | normal |
|
||||
* | Single(normal) | SLOW | normal (degraded — no minimum) |
|
||||
* | Single(normal) | FAST | normal (degraded — no priority) |
|
||||
* | Choosable(min/n/p) | SLOW | minimum |
|
||||
* | Choosable(min/n/p) | MARKET | normal |
|
||||
* | Choosable(min/n/p) | FAST | priority |
|
||||
* | Choosable(min/n/p) | SUGGESTED | normal (caller overrides if applicable) |
|
||||
* | Choosable(min/n/p) | CUSTOM | normal (caller overrides) |
|
||||
*/
|
||||
@TestInstance(TestInstance.Lifecycle.PER_CLASS)
|
||||
internal class SwapFeeFactoryTest {
|
||||
|
||||
private val nativeFeeTokenStatus: CryptoCurrencyStatus = mockk(relaxed = true)
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// TransactionFee.Single
|
||||
// -------------------------------------------------------------------------
|
||||
|
||||
@Test
|
||||
fun `fromLoaded with Single picks the normal fee for MARKET bucket`() {
|
||||
val singleFee = TransactionFee.Single(normal = ethLegacyFee(BigDecimal("0.002")))
|
||||
val result = SwapFeeFactory.fromLoaded(
|
||||
transactionFeeResult = TransactionFeeResult.Loaded(singleFee),
|
||||
selectedFeeToken = nativeFeeTokenStatus,
|
||||
feeBucket = FeeBucket.MARKET,
|
||||
)
|
||||
|
||||
assertThat(result.fee).isEqualTo(singleFee.normal)
|
||||
assertThat(result.feeBucket).isEqualTo(FeeBucket.MARKET)
|
||||
assertThat(result.otherNativeFee).isEqualTo(BigDecimal.ZERO)
|
||||
assertThat(result.selectedFeeToken).isSameInstanceAs(nativeFeeTokenStatus)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `fromLoaded with Single degrades SLOW bucket to normal fee`() {
|
||||
val singleFee = TransactionFee.Single(normal = ethLegacyFee(BigDecimal("0.002")))
|
||||
val result = SwapFeeFactory.fromLoaded(
|
||||
transactionFeeResult = TransactionFeeResult.Loaded(singleFee),
|
||||
selectedFeeToken = nativeFeeTokenStatus,
|
||||
feeBucket = FeeBucket.SLOW,
|
||||
)
|
||||
|
||||
assertThat(result.fee).isEqualTo(singleFee.normal)
|
||||
assertThat(result.feeBucket).isEqualTo(FeeBucket.SLOW)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `fromLoaded with Single degrades FAST bucket to normal fee`() {
|
||||
val singleFee = TransactionFee.Single(normal = ethLegacyFee(BigDecimal("0.002")))
|
||||
val result = SwapFeeFactory.fromLoaded(
|
||||
transactionFeeResult = TransactionFeeResult.Loaded(singleFee),
|
||||
selectedFeeToken = nativeFeeTokenStatus,
|
||||
feeBucket = FeeBucket.FAST,
|
||||
)
|
||||
|
||||
assertThat(result.fee).isEqualTo(singleFee.normal)
|
||||
assertThat(result.feeBucket).isEqualTo(FeeBucket.FAST)
|
||||
}
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// TransactionFee.Choosable
|
||||
// -------------------------------------------------------------------------
|
||||
|
||||
@Test
|
||||
fun `fromLoaded with Choosable picks minimum fee for SLOW bucket`() {
|
||||
val slow = ethLegacyFee(BigDecimal("0.001"))
|
||||
val normal = ethLegacyFee(BigDecimal("0.002"))
|
||||
val fast = ethLegacyFee(BigDecimal("0.003"))
|
||||
val choosable = TransactionFee.Choosable(minimum = slow, normal = normal, priority = fast)
|
||||
|
||||
val result = SwapFeeFactory.fromLoaded(
|
||||
transactionFeeResult = TransactionFeeResult.Loaded(choosable),
|
||||
selectedFeeToken = nativeFeeTokenStatus,
|
||||
feeBucket = FeeBucket.SLOW,
|
||||
)
|
||||
|
||||
assertThat(result.fee).isEqualTo(slow)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `fromLoaded with Choosable picks normal fee for MARKET bucket`() {
|
||||
val slow = ethLegacyFee(BigDecimal("0.001"))
|
||||
val normal = ethLegacyFee(BigDecimal("0.002"))
|
||||
val fast = ethLegacyFee(BigDecimal("0.003"))
|
||||
val choosable = TransactionFee.Choosable(minimum = slow, normal = normal, priority = fast)
|
||||
|
||||
val result = SwapFeeFactory.fromLoaded(
|
||||
transactionFeeResult = TransactionFeeResult.Loaded(choosable),
|
||||
selectedFeeToken = nativeFeeTokenStatus,
|
||||
feeBucket = FeeBucket.MARKET,
|
||||
)
|
||||
|
||||
assertThat(result.fee).isEqualTo(normal)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `fromLoaded with Choosable picks priority fee for FAST bucket`() {
|
||||
val slow = ethLegacyFee(BigDecimal("0.001"))
|
||||
val normal = ethLegacyFee(BigDecimal("0.002"))
|
||||
val fast = ethLegacyFee(BigDecimal("0.003"))
|
||||
val choosable = TransactionFee.Choosable(minimum = slow, normal = normal, priority = fast)
|
||||
|
||||
val result = SwapFeeFactory.fromLoaded(
|
||||
transactionFeeResult = TransactionFeeResult.Loaded(choosable),
|
||||
selectedFeeToken = nativeFeeTokenStatus,
|
||||
feeBucket = FeeBucket.FAST,
|
||||
)
|
||||
|
||||
assertThat(result.fee).isEqualTo(fast)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `fromLoaded with Choosable falls back to normal fee for SUGGESTED bucket`() {
|
||||
val slow = ethLegacyFee(BigDecimal("0.001"))
|
||||
val normal = ethLegacyFee(BigDecimal("0.002"))
|
||||
val fast = ethLegacyFee(BigDecimal("0.003"))
|
||||
val choosable = TransactionFee.Choosable(minimum = slow, normal = normal, priority = fast)
|
||||
|
||||
val result = SwapFeeFactory.fromLoaded(
|
||||
transactionFeeResult = TransactionFeeResult.Loaded(choosable),
|
||||
selectedFeeToken = nativeFeeTokenStatus,
|
||||
feeBucket = FeeBucket.SUGGESTED,
|
||||
)
|
||||
|
||||
assertThat(result.fee).isEqualTo(normal)
|
||||
assertThat(result.feeBucket).isEqualTo(FeeBucket.SUGGESTED)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `fromLoaded with Choosable falls back to normal fee for CUSTOM bucket`() {
|
||||
val slow = ethLegacyFee(BigDecimal("0.001"))
|
||||
val normal = ethLegacyFee(BigDecimal("0.002"))
|
||||
val fast = ethLegacyFee(BigDecimal("0.003"))
|
||||
val choosable = TransactionFee.Choosable(minimum = slow, normal = normal, priority = fast)
|
||||
|
||||
val result = SwapFeeFactory.fromLoaded(
|
||||
transactionFeeResult = TransactionFeeResult.Loaded(choosable),
|
||||
selectedFeeToken = nativeFeeTokenStatus,
|
||||
feeBucket = FeeBucket.CUSTOM,
|
||||
)
|
||||
|
||||
assertThat(result.fee).isEqualTo(normal)
|
||||
assertThat(result.feeBucket).isEqualTo(FeeBucket.CUSTOM)
|
||||
}
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// LoadedExtended (gasless / token fee)
|
||||
// -------------------------------------------------------------------------
|
||||
|
||||
@Test
|
||||
fun `fromLoadedExtended picks normal fee from transactionFeeExtended for MARKET`() {
|
||||
val rawFee = ethLegacyFee(BigDecimal("0.002"))
|
||||
val txFee = TransactionFee.Single(normal = rawFee)
|
||||
val extended = mockk<TransactionFeeExtended>(relaxed = true) {
|
||||
io.mockk.every { transactionFee } returns txFee
|
||||
}
|
||||
|
||||
val result = SwapFeeFactory.fromLoadedExtended(
|
||||
transactionFeeResult = TransactionFeeResult.LoadedExtended(extended),
|
||||
selectedFeeToken = nativeFeeTokenStatus,
|
||||
feeBucket = FeeBucket.MARKET,
|
||||
)
|
||||
|
||||
assertThat(result.fee).isEqualTo(rawFee)
|
||||
assertThat(result.transactionFeeResult).isInstanceOf(TransactionFeeResult.LoadedExtended::class.java)
|
||||
}
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// otherNativeFee propagation
|
||||
// -------------------------------------------------------------------------
|
||||
|
||||
@Test
|
||||
fun `otherNativeFee is propagated verbatim into SwapFee`() {
|
||||
val singleFee = TransactionFee.Single(normal = ethLegacyFee(BigDecimal("0.002")))
|
||||
val bridgeFee = BigDecimal("0.5")
|
||||
|
||||
val result = SwapFeeFactory.fromLoaded(
|
||||
transactionFeeResult = TransactionFeeResult.Loaded(singleFee),
|
||||
selectedFeeToken = nativeFeeTokenStatus,
|
||||
otherNativeFee = bridgeFee,
|
||||
)
|
||||
|
||||
assertThat(result.otherNativeFee).isEquivalentAccordingToCompareTo(bridgeFee)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `default otherNativeFee is ZERO`() {
|
||||
val singleFee = TransactionFee.Single(normal = ethLegacyFee(BigDecimal("0.002")))
|
||||
|
||||
val result = SwapFeeFactory.fromLoaded(
|
||||
transactionFeeResult = TransactionFeeResult.Loaded(singleFee),
|
||||
selectedFeeToken = nativeFeeTokenStatus,
|
||||
)
|
||||
|
||||
assertThat(result.otherNativeFee).isEqualTo(BigDecimal.ZERO)
|
||||
}
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// from() generic dispatcher
|
||||
// -------------------------------------------------------------------------
|
||||
|
||||
@Test
|
||||
fun `from dispatches Loaded to fromLoaded`() {
|
||||
val rawFee = ethLegacyFee(BigDecimal("0.002"))
|
||||
val transactionFeeResult = TransactionFeeResult.Loaded(TransactionFee.Single(normal = rawFee))
|
||||
|
||||
val result = SwapFeeFactory.from(
|
||||
transactionFeeResult = transactionFeeResult,
|
||||
selectedFeeToken = nativeFeeTokenStatus,
|
||||
)
|
||||
|
||||
assertThat(result.fee).isEqualTo(rawFee)
|
||||
assertThat(result.transactionFeeResult).isSameInstanceAs(transactionFeeResult)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `from dispatches LoadedExtended to fromLoadedExtended`() {
|
||||
val rawFee = ethLegacyFee(BigDecimal("0.002"))
|
||||
val txFee = TransactionFee.Single(normal = rawFee)
|
||||
val extended = mockk<TransactionFeeExtended>(relaxed = true) {
|
||||
io.mockk.every { transactionFee } returns txFee
|
||||
}
|
||||
val transactionFeeResult = TransactionFeeResult.LoadedExtended(extended)
|
||||
|
||||
val result = SwapFeeFactory.from(
|
||||
transactionFeeResult = transactionFeeResult,
|
||||
selectedFeeToken = nativeFeeTokenStatus,
|
||||
)
|
||||
|
||||
assertThat(result.fee).isEqualTo(rawFee)
|
||||
assertThat(result.transactionFeeResult).isSameInstanceAs(transactionFeeResult)
|
||||
}
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// FeeBucket.toAnalyticsName labels
|
||||
// -------------------------------------------------------------------------
|
||||
|
||||
@Test
|
||||
fun `FeeBucket toAnalyticsName returns labels compatible with legacy FeeType`() {
|
||||
// SLOW didn't exist in the legacy FeeType; new label is "Min".
|
||||
assertThat(FeeBucket.SLOW.toAnalyticsName()).isEqualTo("Min")
|
||||
// MARKET corresponds to legacy FeeType.NORMAL.getNameForAnalytics() == "Normal".
|
||||
assertThat(FeeBucket.MARKET.toAnalyticsName()).isEqualTo("Normal")
|
||||
// FAST corresponds to legacy FeeType.PRIORITY.getNameForAnalytics() == "Max".
|
||||
assertThat(FeeBucket.FAST.toAnalyticsName()).isEqualTo("Max")
|
||||
assertThat(FeeBucket.SUGGESTED.toAnalyticsName()).isEqualTo("Suggested")
|
||||
assertThat(FeeBucket.CUSTOM.toAnalyticsName()).isEqualTo("Custom")
|
||||
}
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// Helpers
|
||||
// -------------------------------------------------------------------------
|
||||
|
||||
private fun ethLegacyFee(value: BigDecimal): Fee.Ethereum.Legacy = Fee.Ethereum.Legacy(
|
||||
amount = Amount(currencySymbol = "ETH", value = value, decimals = 18),
|
||||
gasLimit = BigInteger.valueOf(100_000),
|
||||
gasPrice = BigInteger.valueOf(20_000_000_000),
|
||||
)
|
||||
}
|
||||
|
|
@ -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")
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
|
|
|
|||
|
|
@ -1,208 +0,0 @@
|
|||
package com.tangem.feature.swap.converters
|
||||
|
||||
import com.tangem.common.getTotalCryptoAmount
|
||||
import com.tangem.common.getTotalFiatAmount
|
||||
import com.tangem.common.ui.R
|
||||
import com.tangem.common.ui.account.AccountCryptoPortfolioItemStateConverter
|
||||
import com.tangem.common.ui.account.TokensListPortfolioItemConverter
|
||||
import com.tangem.common.ui.account.toUM
|
||||
import com.tangem.common.ui.tokens.TokenItemStateConverter
|
||||
import com.tangem.common.ui.tokens.TokenItemStateConverter.Companion.isFlickering
|
||||
import com.tangem.core.ui.components.currency.icon.CurrencyIconState
|
||||
import com.tangem.common.ui.components.currency.icon.converter.CryptoCurrencyToIconStateConverter
|
||||
import com.tangem.core.ui.components.token.state.TokenItemState
|
||||
import com.tangem.core.ui.components.token.state.TokenItemState.FiatAmountState
|
||||
import com.tangem.core.ui.components.tokenlist.state.TokensListItemUM
|
||||
import com.tangem.core.ui.extensions.TextReference
|
||||
import com.tangem.core.ui.extensions.pluralReference
|
||||
import com.tangem.core.ui.extensions.stringReference
|
||||
import com.tangem.core.ui.extensions.wrappedList
|
||||
import com.tangem.core.ui.format.bigdecimal.crypto
|
||||
import com.tangem.core.ui.format.bigdecimal.fiat
|
||||
import com.tangem.core.ui.format.bigdecimal.format
|
||||
import com.tangem.domain.appcurrency.model.AppCurrency
|
||||
import com.tangem.domain.models.StatusSource
|
||||
import com.tangem.domain.models.TotalFiatBalance
|
||||
import com.tangem.domain.models.account.Account
|
||||
import com.tangem.domain.models.account.AccountId
|
||||
import com.tangem.domain.models.currency.CryptoCurrencyStatus
|
||||
import com.tangem.feature.swap.domain.models.ui.AccountSwapAvailability
|
||||
import com.tangem.utils.converter.Converter
|
||||
import com.tangem.utils.extensions.orZero
|
||||
import kotlinx.collections.immutable.toPersistentList
|
||||
|
||||
internal class AccountTokenItemConverter(
|
||||
private val appCurrency: AppCurrency,
|
||||
private val unavailableErrorText: TextReference,
|
||||
private val expandedAccounts: Map<AccountId, Boolean>,
|
||||
private val onTokenItemClick: (Account, CryptoCurrencyStatus) -> Unit,
|
||||
private val onAccountItemClick: (Account) -> Unit,
|
||||
) : Converter<AccountSwapAvailability, TokensListItemUM.Portfolio> {
|
||||
|
||||
override fun convert(value: AccountSwapAvailability): TokensListItemUM.Portfolio {
|
||||
val headerTokenItemState = when (val account = value.account) {
|
||||
is Account.CryptoPortfolio -> AccountCryptoPortfolioItemStateConverter(
|
||||
appCurrency = appCurrency,
|
||||
account = account.copy(cryptoCurrencies = value.currencyList.map { it.cryptoCurrencyStatus.currency }),
|
||||
onItemClick = onAccountItemClick,
|
||||
).convert(
|
||||
TotalFiatBalance.Loaded(
|
||||
amount = value.currencyList.sumOf { it.cryptoCurrencyStatus.value.fiatAmount.orZero() },
|
||||
source = StatusSource.ONLY_CACHE,
|
||||
),
|
||||
)
|
||||
is Account.Payment -> createPaymentAccountHeaderState(value)
|
||||
}
|
||||
return TokensListPortfolioItemConverter(
|
||||
tokenItemUM = headerTokenItemState,
|
||||
isExpanded = expandedAccounts[value.account.accountId] != false,
|
||||
isCollapsable = true,
|
||||
tokens = value.currencyList.map { accountSwapCurrency ->
|
||||
createAvailableItemConverter(value.account)
|
||||
.convert(accountSwapCurrency.cryptoCurrencyStatus)
|
||||
}.map(TokensListItemUM::Token).toPersistentList(),
|
||||
).convert(Unit)
|
||||
}
|
||||
|
||||
private fun createPaymentAccountHeaderState(accountSwapAvailability: AccountSwapAvailability): TokenItemState {
|
||||
val account = accountSwapAvailability.account
|
||||
val tokensCount = accountSwapAvailability.currencyList.size
|
||||
val fiatBalance =
|
||||
accountSwapAvailability.currencyList.sumOf { it.cryptoCurrencyStatus.value.fiatAmount.orZero() }
|
||||
return TokenItemState.Content(
|
||||
id = account.accountId.value,
|
||||
iconState = CurrencyIconState.PaymentAccount(),
|
||||
titleState = TokenItemState.TitleState.Content(text = account.accountName.toUM().value),
|
||||
subtitleState = TokenItemState.SubtitleState.TextContent(
|
||||
value = pluralReference(
|
||||
R.plurals.common_tokens_count,
|
||||
count = tokensCount,
|
||||
formatArgs = wrappedList(tokensCount),
|
||||
),
|
||||
isAvailable = false,
|
||||
),
|
||||
onItemClick = { onAccountItemClick(account) },
|
||||
fiatAmountState = FiatAmountState.Content(
|
||||
text = fiatBalance.format {
|
||||
fiat(
|
||||
fiatCurrencyCode = appCurrency.code,
|
||||
fiatCurrencySymbol = appCurrency.symbol,
|
||||
)
|
||||
},
|
||||
isFlickering = false,
|
||||
),
|
||||
subtitle2State = null,
|
||||
onItemLongClick = null,
|
||||
)
|
||||
}
|
||||
|
||||
fun createAvailableItemConverter(account: Account): TokenItemStateConverter {
|
||||
return TokenItemStateConverter(
|
||||
appCurrency = appCurrency,
|
||||
subtitleStateProvider = { status ->
|
||||
createSubtitleState(
|
||||
status = status,
|
||||
isAvailable = true,
|
||||
text = stringReference(value = status.currency.symbol),
|
||||
)
|
||||
},
|
||||
subtitle2StateProvider = ::createSubtitle2State,
|
||||
fiatAmountStateProvider = {
|
||||
createFiatAmountStateProvider(status = it, appCurrency = appCurrency, isAvailable = true)
|
||||
},
|
||||
onItemClick = { _, currencyStatus -> onTokenItemClick(account, currencyStatus) },
|
||||
)
|
||||
}
|
||||
|
||||
fun createUnavailableItemConverter(): TokenItemStateConverter {
|
||||
return TokenItemStateConverter(
|
||||
appCurrency = appCurrency,
|
||||
iconStateProvider = { CryptoCurrencyToIconStateConverter(isAvailable = false).convert(it) },
|
||||
titleStateProvider = { status ->
|
||||
TokenItemState.TitleState.Content(
|
||||
text = stringReference(value = status.currency.name),
|
||||
isAvailable = false,
|
||||
)
|
||||
},
|
||||
subtitleStateProvider = { status ->
|
||||
createSubtitleState(
|
||||
status = status,
|
||||
isAvailable = false,
|
||||
text = unavailableErrorText,
|
||||
)
|
||||
},
|
||||
subtitle2StateProvider = ::createSubtitle2State,
|
||||
fiatAmountStateProvider = {
|
||||
createFiatAmountStateProvider(status = it, appCurrency = appCurrency, isAvailable = false)
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
private fun createSubtitleState(
|
||||
status: CryptoCurrencyStatus,
|
||||
isAvailable: Boolean,
|
||||
text: TextReference,
|
||||
): TokenItemState.SubtitleState {
|
||||
return when (status.value) {
|
||||
CryptoCurrencyStatus.Loading -> TokenItemState.SubtitleState.Loading
|
||||
else -> {
|
||||
TokenItemState.SubtitleState.TextContent(
|
||||
value = text,
|
||||
isAvailable = isAvailable,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun createSubtitle2State(status: CryptoCurrencyStatus): TokenItemState.Subtitle2State? {
|
||||
return when (status.value) {
|
||||
is CryptoCurrencyStatus.Loaded,
|
||||
is CryptoCurrencyStatus.Custom,
|
||||
is CryptoCurrencyStatus.NoQuote,
|
||||
is CryptoCurrencyStatus.NoAccount,
|
||||
-> {
|
||||
TokenItemState.Subtitle2State.TextContent(
|
||||
text = status.getTotalCryptoAmount().format {
|
||||
crypto(cryptoCurrency = status.currency)
|
||||
},
|
||||
isFlickering = status.value.isFlickering(),
|
||||
)
|
||||
}
|
||||
is CryptoCurrencyStatus.Loading,
|
||||
is CryptoCurrencyStatus.MissedDerivation,
|
||||
is CryptoCurrencyStatus.Unreachable,
|
||||
is CryptoCurrencyStatus.NoAmount,
|
||||
-> null
|
||||
}
|
||||
}
|
||||
|
||||
private fun createFiatAmountStateProvider(
|
||||
status: CryptoCurrencyStatus,
|
||||
appCurrency: AppCurrency,
|
||||
isAvailable: Boolean,
|
||||
): FiatAmountState? {
|
||||
return when (status.value) {
|
||||
is CryptoCurrencyStatus.Loaded,
|
||||
is CryptoCurrencyStatus.Custom,
|
||||
is CryptoCurrencyStatus.NoQuote,
|
||||
is CryptoCurrencyStatus.NoAccount,
|
||||
-> {
|
||||
FiatAmountState.TextContent(
|
||||
text = status.getTotalFiatAmount().format {
|
||||
fiat(
|
||||
fiatCurrencyCode = appCurrency.code,
|
||||
fiatCurrencySymbol = appCurrency.symbol,
|
||||
)
|
||||
},
|
||||
isAvailable = isAvailable,
|
||||
isFlickering = status.value.isFlickering(),
|
||||
)
|
||||
}
|
||||
is CryptoCurrencyStatus.Unreachable,
|
||||
is CryptoCurrencyStatus.NoAmount,
|
||||
is CryptoCurrencyStatus.MissedDerivation,
|
||||
is CryptoCurrencyStatus.Loading,
|
||||
-> null
|
||||
}
|
||||
}
|
||||
}
|
||||
File diff suppressed because it is too large
Load diff
|
|
@ -6,29 +6,35 @@ import com.tangem.common.routing.AppRouter
|
|||
import com.tangem.common.ui.notifications.NotificationUM
|
||||
import com.tangem.common.ui.notifications.NotificationsFactory.addDustWarningNotification
|
||||
import com.tangem.common.ui.notifications.NotificationsFactory.addExistentialWarningNotification
|
||||
import com.tangem.common.ui.notifications.NotificationsFactory.addFeeUnreachableNotification
|
||||
import com.tangem.common.ui.notifications.NotificationsFactory.addReserveAmountErrorNotification
|
||||
import com.tangem.common.ui.notifications.NotificationsFactory.addTransactionLimitErrorNotification
|
||||
import com.tangem.common.ui.notifications.NotificationsFactory.addValidateTransactionNotifications
|
||||
import com.tangem.core.ui.extensions.TextReference
|
||||
import com.tangem.core.ui.format.bigdecimal.crypto
|
||||
import com.tangem.core.ui.format.bigdecimal.fiat
|
||||
import com.tangem.core.ui.format.bigdecimal.format
|
||||
import com.tangem.core.ui.utils.parseBigDecimal
|
||||
import com.tangem.domain.appcurrency.model.AppCurrency
|
||||
import com.tangem.domain.express.models.ExpressError
|
||||
import com.tangem.domain.models.currency.CryptoCurrency
|
||||
import com.tangem.domain.models.currency.CryptoCurrencyStatus
|
||||
import com.tangem.domain.transaction.error.GetFeeError
|
||||
import com.tangem.domain.transaction.usecase.gasless.IsGaslessFeeSupportedForNetwork
|
||||
import com.tangem.feature.swap.domain.models.ExpressDataError
|
||||
import com.tangem.feature.swap.domain.models.SwapAmount
|
||||
import com.tangem.feature.swap.domain.models.domain.ExchangeProviderType
|
||||
import com.tangem.feature.swap.domain.models.domain.IncludeFeeInAmount
|
||||
import com.tangem.feature.swap.domain.models.domain.SwapFeeState
|
||||
import com.tangem.feature.swap.domain.models.ui.*
|
||||
import com.tangem.feature.swap.domain.models.domain.SwapBalanceStatus
|
||||
import com.tangem.feature.swap.domain.models.ui.PermissionDataState
|
||||
import com.tangem.feature.swap.domain.models.ui.PriceImpact
|
||||
import com.tangem.feature.swap.domain.models.ui.SwapFee
|
||||
import com.tangem.feature.swap.domain.models.ui.SwapState
|
||||
import com.tangem.feature.swap.models.UiActions
|
||||
import com.tangem.feature.swap.models.states.FeeItemState
|
||||
import com.tangem.feature.swap.models.states.SwapNotificationUM
|
||||
import com.tangem.lib.crypto.BlockchainUtils
|
||||
import com.tangem.lib.crypto.BlockchainUtils.getTezosThreshold
|
||||
import com.tangem.lib.crypto.BlockchainUtils.isTezos
|
||||
import com.tangem.utils.Provider
|
||||
import com.tangem.utils.extensions.orZero
|
||||
import kotlinx.collections.immutable.ImmutableList
|
||||
import kotlinx.collections.immutable.persistentListOf
|
||||
|
|
@ -39,6 +45,7 @@ import java.math.BigDecimal
|
|||
internal class SwapNotificationsFactory(
|
||||
private val actions: UiActions,
|
||||
private val isGaslessFeeSupportedForNetwork: IsGaslessFeeSupportedForNetwork,
|
||||
private val appCurrencyProvider: Provider<AppCurrency> = Provider { AppCurrency.Default },
|
||||
) {
|
||||
|
||||
fun getGeneralErrorStateNotifications(
|
||||
|
|
@ -74,18 +81,13 @@ internal class SwapNotificationsFactory(
|
|||
fun getQuotesErrorStateNotifications(
|
||||
expressDataError: ExpressDataError,
|
||||
fromToken: CryptoCurrency,
|
||||
feeItem: FeeItemState,
|
||||
includeFeeInAmount: IncludeFeeInAmount,
|
||||
balanceStatus: SwapBalanceStatus,
|
||||
swapFee: SwapFee?,
|
||||
): ImmutableList<NotificationUM> {
|
||||
return buildList {
|
||||
add(getWarningForError(expressDataError, fromToken, actions.onRetryClick))
|
||||
if (includeFeeInAmount is IncludeFeeInAmount.Included && feeItem is FeeItemState.Content) {
|
||||
add(
|
||||
NotificationUM.Warning.FeeCoverageNotification(
|
||||
feeItem.amountCrypto,
|
||||
feeItem.amountFiatFormatted,
|
||||
),
|
||||
)
|
||||
if (balanceStatus is SwapBalanceStatus.FeeAdjustedAmount && swapFee != null) {
|
||||
add(formatFeeCoverageNotification(swapFee))
|
||||
}
|
||||
}.toPersistentList()
|
||||
}
|
||||
|
|
@ -102,26 +104,21 @@ internal class SwapNotificationsFactory(
|
|||
return updatedNotifications.toPersistentList()
|
||||
}
|
||||
|
||||
@Suppress("LongParameterList")
|
||||
fun getConfirmationStateNotifications(
|
||||
quoteModel: SwapState.QuotesLoadedState,
|
||||
feeCryptoCurrencyStatus: CryptoCurrencyStatus?,
|
||||
selectedFeeType: FeeType,
|
||||
hideFee: Boolean,
|
||||
swapFee: SwapFee?,
|
||||
feeError: GetFeeError?,
|
||||
appRouter: AppRouter,
|
||||
): ImmutableList<NotificationUM> {
|
||||
val warnings = buildList {
|
||||
maybeAddFeeErrorNotification(feeCryptoCurrencyStatus, quoteModel, feeError)
|
||||
maybeAddRentExemptionError(quoteModel)
|
||||
maybeAddDomainWarnings(quoteModel, feeCryptoCurrencyStatus, selectedFeeType)
|
||||
maybeAddDomainWarnings(quoteModel, feeCryptoCurrencyStatus, swapFee)
|
||||
maybeAddNeedReserveToCreateAccountWarning(quoteModel)
|
||||
maybeAddPermissionNeededWarning(quoteModel)
|
||||
maybeAddNetworkFeeCoverageWarning(quoteModel, selectedFeeType)
|
||||
maybeAddUnableCoverFeeWarning(
|
||||
quoteModel = quoteModel,
|
||||
feeCryptoCurrencyStatus = feeCryptoCurrencyStatus,
|
||||
hideFee = hideFee,
|
||||
appRouter = appRouter,
|
||||
)
|
||||
maybeAddNetworkFeeCoverageWarning(quoteModel, swapFee)
|
||||
maybeAddUnableCoverFeeWarning(quoteModel, feeCryptoCurrencyStatus, appRouter)
|
||||
maybeAddTransactionInProgressWarning(quoteModel)
|
||||
maybeAddPriceImpactNotification(quoteModel.priceImpact)
|
||||
}
|
||||
|
|
@ -161,35 +158,22 @@ internal class SwapNotificationsFactory(
|
|||
add(notification)
|
||||
}
|
||||
|
||||
@Suppress("LongMethod")
|
||||
private fun MutableList<NotificationUM>.maybeAddDomainWarnings(
|
||||
quoteModel: SwapState.QuotesLoadedState,
|
||||
feeCryptoCurrencyStatus: CryptoCurrencyStatus?,
|
||||
selectedFeeType: FeeType,
|
||||
swapFee: SwapFee?,
|
||||
) {
|
||||
val swapCurrencyStatus = quoteModel.fromTokenInfo.swapCurrencyStatus
|
||||
val includeFeeInAmount = quoteModel.preparedSwapConfigState.includeFeeInAmount
|
||||
val balanceStatus = quoteModel.preparedSwapConfigState.balanceStatus
|
||||
val amount = quoteModel.fromTokenInfo.tokenAmount
|
||||
val amountToRequest = if (includeFeeInAmount is IncludeFeeInAmount.Included) {
|
||||
includeFeeInAmount.amountSubtractFee
|
||||
} else {
|
||||
amount
|
||||
}
|
||||
val fee = when (val feeState = quoteModel.txFee) {
|
||||
TxFeeState.Empty -> null
|
||||
is TxFeeState.MultipleFeeState -> if (feeState.normalFee.feeType == selectedFeeType) {
|
||||
feeState.normalFee
|
||||
} else {
|
||||
feeState.priorityFee
|
||||
}
|
||||
is TxFeeState.SingleFeeState -> feeState.fee
|
||||
}
|
||||
val amountToRequest = (balanceStatus as? SwapBalanceStatus.FeeAdjustedAmount)?.adjustedAmount ?: amount
|
||||
val feeValue = swapFee?.fee?.amount?.value.orZero()
|
||||
val isCardano = BlockchainUtils.isCardano(swapCurrencyStatus.currency.network.rawId)
|
||||
// blockchain specific
|
||||
|
||||
addExistentialWarningNotification(
|
||||
existentialDeposit = quoteModel.currencyCheck?.existentialDeposit,
|
||||
feeAmount = fee?.fee?.amount?.value.orZero(),
|
||||
feeAmount = feeValue,
|
||||
sendingAmount = amountToRequest.value,
|
||||
cryptoCurrencyStatus = swapCurrencyStatus.status,
|
||||
onReduceClick = { reduceBy, reduceByDiff, _ ->
|
||||
|
|
@ -212,7 +196,7 @@ internal class SwapNotificationsFactory(
|
|||
if (!isCardano) {
|
||||
addDustWarningNotification(
|
||||
dustValue = quoteModel.currencyCheck?.dustValue,
|
||||
feeValue = fee?.fee?.amount?.value.orZero(),
|
||||
feeValue = feeValue,
|
||||
sendingAmount = amountToRequest.value,
|
||||
cryptoCurrencyStatus = swapCurrencyStatus.status,
|
||||
feeCurrencyStatus = feeCryptoCurrencyStatus,
|
||||
|
|
@ -235,7 +219,7 @@ internal class SwapNotificationsFactory(
|
|||
sendingAmount = amountToRequest.value,
|
||||
cryptoCurrencyStatus = swapCurrencyStatus.status,
|
||||
feeCurrencyStatus = feeCryptoCurrencyStatus,
|
||||
feeValue = fee?.feeValue.orZero(),
|
||||
feeValue = feeValue,
|
||||
onReduceClick = { reduceTo, _ ->
|
||||
actions.onReduceToAmount(amountToRequest.copy(value = reduceTo))
|
||||
},
|
||||
|
|
@ -272,59 +256,59 @@ internal class SwapNotificationsFactory(
|
|||
}
|
||||
}
|
||||
|
||||
@Suppress("CanBeNonNullable")
|
||||
private fun MutableList<NotificationUM>.maybeAddNetworkFeeCoverageWarning(
|
||||
quoteModel: SwapState.QuotesLoadedState,
|
||||
selectedFeeType: FeeType,
|
||||
swapFee: SwapFee?,
|
||||
) {
|
||||
when (quoteModel.preparedSwapConfigState.includeFeeInAmount) {
|
||||
is IncludeFeeInAmount.Included -> {
|
||||
val fee = selectFeeByType(selectedFeeType, quoteModel.txFee) ?: return
|
||||
when (quoteModel.preparedSwapConfigState.balanceStatus) {
|
||||
is SwapBalanceStatus.FeeAdjustedAmount -> {
|
||||
if (swapFee == null) return
|
||||
if (needShowNetworkFeeCoverageWarningShow(quoteModel)) {
|
||||
add(
|
||||
NotificationUM.Warning.FeeCoverageNotification(
|
||||
fee.feeCryptoFormattedWithNative,
|
||||
fee.feeFiatFormattedWithNative,
|
||||
),
|
||||
)
|
||||
add(formatFeeCoverageNotification(swapFee))
|
||||
}
|
||||
}
|
||||
else -> Unit
|
||||
}
|
||||
}
|
||||
|
||||
private fun selectFeeByType(feeType: FeeType, txFeeState: TxFeeState): TxFee.Legacy? {
|
||||
return when (txFeeState) {
|
||||
TxFeeState.Empty -> null
|
||||
is TxFeeState.SingleFeeState -> txFeeState.fee
|
||||
is TxFeeState.MultipleFeeState -> when (feeType) {
|
||||
FeeType.NORMAL -> txFeeState.normalFee
|
||||
FeeType.PRIORITY -> txFeeState.priorityFee
|
||||
}
|
||||
private fun formatFeeCoverageNotification(swapFee: SwapFee): NotificationUM.Warning.FeeCoverageNotification {
|
||||
val feeAmount = swapFee.fee.amount
|
||||
val totalFeeValue = (feeAmount.value ?: BigDecimal.ZERO) + swapFee.otherNativeFee
|
||||
val cryptoAmount = totalFeeValue.format {
|
||||
crypto(symbol = feeAmount.currencySymbol, decimals = feeAmount.decimals)
|
||||
}
|
||||
val appCurrency = appCurrencyProvider()
|
||||
val fiatRate = swapFee.selectedFeeToken.value.fiatRate
|
||||
val fiatAmount = fiatRate?.multiply(totalFeeValue).format {
|
||||
fiat(fiatCurrencyCode = appCurrency.code, fiatCurrencySymbol = appCurrency.symbol)
|
||||
}
|
||||
return NotificationUM.Warning.FeeCoverageNotification(
|
||||
cryptoAmount = cryptoAmount,
|
||||
fiatAmount = fiatAmount,
|
||||
)
|
||||
}
|
||||
|
||||
@Suppress("CyclomaticComplexMethod")
|
||||
@Suppress("CyclomaticComplexMethod", "CanBeNonNullable")
|
||||
private fun MutableList<NotificationUM>.maybeAddUnableCoverFeeWarning(
|
||||
quoteModel: SwapState.QuotesLoadedState,
|
||||
feeCryptoCurrencyStatus: CryptoCurrencyStatus?,
|
||||
hideFee: Boolean,
|
||||
appRouter: AppRouter,
|
||||
) {
|
||||
if (hideFee || feeCryptoCurrencyStatus == null) return
|
||||
if (feeCryptoCurrencyStatus == null) return
|
||||
val fromSwapCurrency = quoteModel.fromTokenInfo.swapCurrencyStatus
|
||||
val fromCurrency = fromSwapCurrency.currency
|
||||
val feeEnoughState = quoteModel.preparedSwapConfigState.feeState as? SwapFeeState.NotEnough
|
||||
val shouldShowCoverWarning = !quoteModel.preparedSwapConfigState.isBalanceEnough &&
|
||||
quoteModel.permissionState !is PermissionDataState.PermissionLoading &&
|
||||
val balanceStatus = quoteModel.preparedSwapConfigState.balanceStatus
|
||||
val insufficientFee = balanceStatus as? SwapBalanceStatus.InsufficientFee
|
||||
val shouldShowCoverWarning = quoteModel.permissionState !is PermissionDataState.PermissionLoading &&
|
||||
feeCryptoCurrencyStatus.currency != fromCurrency
|
||||
|
||||
val isCEXProvider = quoteModel.swapProvider.type == ExchangeProviderType.CEX
|
||||
|
||||
val isNotEnoughFee = feeEnoughState is SwapFeeState.NotEnough && !isCEXProvider ||
|
||||
quoteModel.preparedSwapConfigState.includeFeeInAmount is IncludeFeeInAmount.BalanceNotEnough
|
||||
val isNotEnoughFee = insufficientFee != null && !isCEXProvider
|
||||
|
||||
val isGaslessAvailable = isGaslessFeeSupportedForNetwork(fromCurrency.network) && isCEXProvider
|
||||
if (shouldShowCoverWarning && !isGaslessAvailable || isNotEnoughFee) {
|
||||
if (shouldShowCoverWarning && !isGaslessAvailable && isNotEnoughFee) {
|
||||
add(
|
||||
if (fromCurrency.id == feeCryptoCurrencyStatus.currency.id) {
|
||||
SwapNotificationUM.Error.InsufficientFunds
|
||||
|
|
@ -336,8 +320,8 @@ internal class SwapNotificationsFactory(
|
|||
SwapNotificationUM.Error.UnableToCoverFeeWarning(
|
||||
fromToken = fromCurrency,
|
||||
feeCurrency = feeCryptoCurrencyStatus.currency,
|
||||
currencyName = feeEnoughState?.currencyName ?: fromCurrency.network.name,
|
||||
currencySymbol = feeEnoughState?.currencySymbol ?: fromCurrency.network.currencySymbol,
|
||||
currencyName = insufficientFee.feeCurrencyName ?: fromCurrency.network.name,
|
||||
currencySymbol = insufficientFee.feeCurrencySymbol ?: fromCurrency.network.currencySymbol,
|
||||
onConfirmClick = if (!appRouter.stack.contains(route)) {
|
||||
{ appRouter.push(route) }
|
||||
} else {
|
||||
|
|
@ -349,6 +333,52 @@ internal class SwapNotificationsFactory(
|
|||
}
|
||||
}
|
||||
|
||||
private fun MutableList<NotificationUM>.maybeAddFeeErrorNotification(
|
||||
feeCryptoCurrencyStatus: CryptoCurrencyStatus?,
|
||||
quoteModel: SwapState.QuotesLoadedState,
|
||||
feeError: GetFeeError?,
|
||||
) {
|
||||
if (
|
||||
feeError == null || feeCryptoCurrencyStatus == null ||
|
||||
quoteModel.permissionState !is PermissionDataState.Empty
|
||||
) {
|
||||
return
|
||||
}
|
||||
|
||||
when (feeError) {
|
||||
is GetFeeError.DataError -> {
|
||||
val error = feeError.cause
|
||||
if (error is ExpressDataError) {
|
||||
addAll(
|
||||
getQuotesErrorStateNotifications(
|
||||
expressDataError = error,
|
||||
fromToken = quoteModel.fromTokenInfo.swapCurrencyStatus.currency,
|
||||
balanceStatus = quoteModel.preparedSwapConfigState.balanceStatus,
|
||||
swapFee = null,
|
||||
),
|
||||
)
|
||||
} else {
|
||||
addFeeUnreachableNotification(
|
||||
tokenStatus = quoteModel.fromTokenInfo.swapCurrencyStatus.status,
|
||||
coinStatus = feeCryptoCurrencyStatus,
|
||||
feeError = feeError,
|
||||
dustValue = quoteModel.currencyCheck?.dustValue,
|
||||
onReload = actions.onRetryClick,
|
||||
onClick = actions.openTokenDetailsScreen,
|
||||
)
|
||||
}
|
||||
}
|
||||
else -> addFeeUnreachableNotification(
|
||||
tokenStatus = quoteModel.fromTokenInfo.swapCurrencyStatus.status,
|
||||
coinStatus = feeCryptoCurrencyStatus,
|
||||
feeError = feeError,
|
||||
dustValue = quoteModel.currencyCheck?.dustValue,
|
||||
onReload = actions.onRetryClick,
|
||||
onClick = actions.openTokenDetailsScreen,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
private fun MutableList<NotificationUM>.addReduceAmountNotification(
|
||||
cryptoCurrencyStatus: CryptoCurrencyStatus,
|
||||
fromAmount: SwapAmount,
|
||||
|
|
@ -438,7 +468,7 @@ internal fun ExpressDataError.toExpressError(): ExpressError = when (this) {
|
|||
is ExpressDataError.InvalidRequestIdError -> ExpressError.InvalidRequestIdError(code)
|
||||
is ExpressDataError.InvalidPayoutAddressError -> ExpressError.InvalidPayoutAddressError(code)
|
||||
is ExpressDataError.UnknownErrorWithCode -> ExpressError.InternalError(code)
|
||||
ExpressDataError.UnknownError -> ExpressError.UnknownError
|
||||
ExpressDataError.TooLargeSolanaTransactionError -> ExpressError.TooLargeSolanaTransactionError()
|
||||
ExpressDataError.DexActiveSupplyError -> ExpressError.DexActiveSupplyError()
|
||||
is ExpressDataError.UnknownError -> ExpressError.UnknownError
|
||||
is ExpressDataError.TooLargeSolanaTransactionError -> ExpressError.TooLargeSolanaTransactionError()
|
||||
is ExpressDataError.DexActiveSupplyError -> ExpressError.DexActiveSupplyError()
|
||||
}
|
||||
|
|
@ -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? {
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
)
|
||||
|
|
@ -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,
|
||||
|
||||
|
|
|
|||
|
|
@ -10,6 +10,7 @@ data class SwapSuccessStateHolder(
|
|||
val fee: TextReference?,
|
||||
val rate: TextReference,
|
||||
val shouldShowStatusButton: Boolean,
|
||||
val isTransferMode: Boolean,
|
||||
val providerName: TextReference,
|
||||
val providerType: TextReference,
|
||||
val providerIcon: String,
|
||||
|
|
@ -23,4 +24,7 @@ data class SwapSuccessStateHolder(
|
|||
val toTokenIconState: CurrencyIconState?,
|
||||
val onExploreButtonClick: () -> Unit,
|
||||
val onStatusButtonClick: () -> Unit,
|
||||
)
|
||||
) {
|
||||
val shouldShowProvider: Boolean
|
||||
get() = !isTransferMode
|
||||
}
|
||||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -1,15 +0,0 @@
|
|||
package com.tangem.feature.swap.models.states
|
||||
|
||||
import com.tangem.core.ui.components.bottomsheets.TangemBottomSheetConfigContent
|
||||
import com.tangem.core.ui.extensions.TextReference
|
||||
import com.tangem.feature.swap.domain.models.ui.FeeType
|
||||
import kotlinx.collections.immutable.ImmutableList
|
||||
|
||||
data class ChooseFeeBottomSheetConfig(
|
||||
val selectedFee: FeeType,
|
||||
val onSelectFeeType: (FeeType) -> Unit,
|
||||
val feeItems: ImmutableList<FeeItemState.Content>,
|
||||
val readMoreUrl: String,
|
||||
val readMore: TextReference,
|
||||
val onReadMoreClick: (String) -> Unit,
|
||||
) : TangemBottomSheetConfigContent
|
||||
|
|
@ -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()
|
||||
}
|
||||
|
|
@ -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)
|
||||
}
|
||||
|
|
@ -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"),
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
@ -1,62 +0,0 @@
|
|||
package com.tangem.feature.swap.ui
|
||||
|
||||
import android.content.res.Configuration
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.clickable
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.draw.clip
|
||||
import androidx.compose.ui.tooling.preview.Preview
|
||||
import androidx.compose.ui.tooling.preview.PreviewParameter
|
||||
import androidx.compose.ui.tooling.preview.PreviewParameterProvider
|
||||
import com.tangem.core.ui.components.inputrow.InputRowDefault
|
||||
import com.tangem.core.ui.extensions.stringReference
|
||||
import com.tangem.core.ui.res.TangemTheme
|
||||
import com.tangem.core.ui.res.TangemThemePreview
|
||||
import com.tangem.feature.swap.models.states.FeeItemState
|
||||
import com.tangem.feature.swap.presentation.R
|
||||
import com.tangem.feature.swap.preview.FeeItemStatePreview
|
||||
|
||||
@Composable
|
||||
fun FeeItemBlock(state: FeeItemState) {
|
||||
if (state is FeeItemState.Content) {
|
||||
FeeItem(state = state)
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun FeeItem(state: FeeItemState.Content) {
|
||||
val description = "${state.amountCrypto} ${state.symbolCrypto} (${state.amountFiatFormatted})"
|
||||
val icon = R.drawable.ic_chevron_right_24.takeIf { state.isClickable }
|
||||
InputRowDefault(
|
||||
title = state.title,
|
||||
text = stringReference(description),
|
||||
iconRes = icon,
|
||||
modifier = Modifier
|
||||
.clip(shape = TangemTheme.shapes.roundedCornersXMedium)
|
||||
.background(color = TangemTheme.colors.background.action)
|
||||
.clickable(
|
||||
enabled = state.isClickable,
|
||||
onClick = state.onClick,
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
// region Preview
|
||||
@Preview(showBackground = true, widthDp = 360)
|
||||
@Preview(showBackground = true, widthDp = 360, uiMode = Configuration.UI_MODE_NIGHT_YES)
|
||||
@Composable
|
||||
private fun FeeItem_Preview(@PreviewParameter(FeeItemPreviewProvider::class) data: FeeItemState.Content) {
|
||||
TangemThemePreview {
|
||||
FeeItem(data)
|
||||
}
|
||||
}
|
||||
|
||||
private class FeeItemPreviewProvider : PreviewParameterProvider<FeeItemState.Content> {
|
||||
override val values: Sequence<FeeItemState.Content>
|
||||
get() = sequenceOf(
|
||||
FeeItemStatePreview.state,
|
||||
FeeItemStatePreview.state.copy(isClickable = true),
|
||||
)
|
||||
}
|
||||
// endregion
|
||||
|
|
@ -27,6 +27,7 @@ import com.tangem.domain.models.currency.CryptoCurrency
|
|||
import com.tangem.domain.models.currency.CryptoCurrencyStatus
|
||||
import com.tangem.domain.models.wallet.isHotWallet
|
||||
import com.tangem.domain.swap.models.SwapCurrencyStatus
|
||||
import com.tangem.domain.transaction.error.GetFeeError
|
||||
import com.tangem.domain.transaction.usecase.gasless.IsGaslessFeeSupportedForNetwork
|
||||
import com.tangem.feature.swap.converters.SwapProviderStateBuilder
|
||||
import com.tangem.feature.swap.domain.models.ExpressDataError
|
||||
|
|
@ -40,6 +41,7 @@ import com.tangem.feature.swap.models.SwapButton.Mode
|
|||
import com.tangem.feature.swap.models.states.*
|
||||
import com.tangem.feature.swap.presentation.R
|
||||
import com.tangem.feature.swap.utils.formatToUIRepresentation
|
||||
import com.tangem.features.send.v2.api.entity.FeeSelectorUM
|
||||
import com.tangem.features.swap.SwapFeatureToggles
|
||||
import com.tangem.utils.Provider
|
||||
import com.tangem.utils.StringsSigns
|
||||
|
|
@ -67,7 +69,11 @@ internal class StateBuilder(
|
|||
private val iconStateConverter by lazy(::CryptoCurrencyToIconStateConverter)
|
||||
|
||||
private val notificationsFactory by lazy(LazyThreadSafetyMode.NONE) {
|
||||
SwapNotificationsFactory(actions, isGaslessFeeSupportedForNetwork)
|
||||
SwapNotificationsFactory(
|
||||
actions = actions,
|
||||
isGaslessFeeSupportedForNetwork = isGaslessFeeSupportedForNetwork,
|
||||
appCurrencyProvider = appCurrencyProvider,
|
||||
)
|
||||
}
|
||||
|
||||
fun createInitialLoadingState(swapUIMode: SwapUIMode = SwapUIMode.Detailed): SwapStateHolder {
|
||||
|
|
@ -80,7 +86,6 @@ internal class StateBuilder(
|
|||
isFromCard = false,
|
||||
emptyAmountState = SwapState.EmptyAmountState(TextReference.EMPTY),
|
||||
),
|
||||
fee = FeeItemState.Empty,
|
||||
swapButton = SwapButton(
|
||||
walletInteractionIcon = null,
|
||||
isEnabled = false,
|
||||
|
|
@ -127,7 +132,6 @@ internal class StateBuilder(
|
|||
),
|
||||
notifications = persistentListOf(),
|
||||
isInsufficientFunds = false,
|
||||
fee = FeeItemState.Empty,
|
||||
swapButton = SwapButton(
|
||||
walletInteractionIcon = fromSwapCurrencyStatus?.userWallet?.let(::walletInterationIcon),
|
||||
isEnabled = false,
|
||||
|
|
@ -158,7 +162,6 @@ internal class StateBuilder(
|
|||
onRetryClick = onRetry,
|
||||
),
|
||||
permissionUM = SwapPermissionUM.Empty,
|
||||
fee = FeeItemState.Empty,
|
||||
swapButton = fromSwapCurrencyStatus?.let {
|
||||
SwapButton(
|
||||
walletInteractionIcon = walletInterationIcon(fromSwapCurrencyStatus.userWallet),
|
||||
|
|
@ -199,7 +202,6 @@ internal class StateBuilder(
|
|||
),
|
||||
),
|
||||
notifications = persistentListOf(),
|
||||
fee = FeeItemState.Empty,
|
||||
swapButton = SwapButton(
|
||||
walletInteractionIcon = walletInterationIcon(fromSwapCurrencyStatus.userWallet),
|
||||
isEnabled = false,
|
||||
|
|
@ -237,7 +239,6 @@ internal class StateBuilder(
|
|||
),
|
||||
notifications = persistentListOf(),
|
||||
isInsufficientFunds = false,
|
||||
fee = FeeItemState.Empty,
|
||||
swapButton = SwapButton(
|
||||
walletInteractionIcon = fromSwapCurrencyStatus?.userWallet?.let(::walletInterationIcon),
|
||||
isEnabled = false,
|
||||
|
|
@ -390,7 +391,6 @@ internal class StateBuilder(
|
|||
isBalanceHidden = isBalanceHiddenProvider(),
|
||||
),
|
||||
notifications = notificationsFactory.getSwapNotSupportedNotifications(),
|
||||
fee = FeeItemState.Empty,
|
||||
swapButton = SwapButton(
|
||||
walletInteractionIcon = walletInterationIcon(fromSwapCurrencyStatus.userWallet),
|
||||
isEnabled = false,
|
||||
|
|
@ -431,7 +431,6 @@ internal class StateBuilder(
|
|||
amountEquivalent = null,
|
||||
),
|
||||
notifications = persistentListOf(),
|
||||
fee = FeeItemState.Empty,
|
||||
swapButton = SwapButton(
|
||||
walletInteractionIcon = walletInterationIcon(fromSwapCurrencyStatus.userWallet),
|
||||
isEnabled = false,
|
||||
|
|
@ -454,13 +453,12 @@ internal class StateBuilder(
|
|||
swapProvider: SwapProvider,
|
||||
bestRatedProviderId: String,
|
||||
isNeedBestRateBadge: Boolean,
|
||||
selectedFeeType: FeeType,
|
||||
needApplyFCARestrictions: Boolean,
|
||||
hideFee: Boolean,
|
||||
swapFee: SwapFee?,
|
||||
feeError: FeeSelectorUM.Error?,
|
||||
): SwapStateHolder {
|
||||
if (uiStateHolder.sendCardData !is SwapCardState.SwapCardData) return uiStateHolder
|
||||
if (uiStateHolder.receiveCardData !is SwapCardState.SwapCardData) return uiStateHolder
|
||||
val feeState = if (hideFee) FeeItemState.Empty else createFeeState(quoteModel.txFee, selectedFeeType)
|
||||
val fromSwapCurrencyStatus = quoteModel.fromTokenInfo.swapCurrencyStatus
|
||||
val toSwapCurrencyStatus = quoteModel.toTokenInfo.swapCurrencyStatus
|
||||
val isInsufficientFunds = isInsufficientFundsCondition(quoteModel)
|
||||
|
|
@ -468,8 +466,8 @@ internal class StateBuilder(
|
|||
val notifications = notificationsFactory.getConfirmationStateNotifications(
|
||||
quoteModel = quoteModel,
|
||||
feeCryptoCurrencyStatus = feeCryptoCurrencyStatus,
|
||||
selectedFeeType = selectedFeeType,
|
||||
hideFee = hideFee,
|
||||
swapFee = swapFee,
|
||||
feeError = feeError?.error,
|
||||
appRouter = appRouter,
|
||||
)
|
||||
|
||||
|
|
@ -547,10 +545,9 @@ internal class StateBuilder(
|
|||
permissionUM = convertPermissionState(
|
||||
permissionDataState = quoteModel.permissionState,
|
||||
),
|
||||
fee = feeState,
|
||||
swapButton = SwapButton(
|
||||
walletInteractionIcon = walletInterationIcon(fromSwapCurrencyStatus.userWallet),
|
||||
isEnabled = getSwapButtonEnabled(notifications, priceImpact),
|
||||
isEnabled = getSwapButtonEnabled(notifications, priceImpact, swapFee),
|
||||
isHoldToConfirm = fromSwapCurrencyStatus.userWallet.isHotWallet,
|
||||
onClick = actions.onSwapClick,
|
||||
),
|
||||
|
|
@ -572,6 +569,34 @@ internal class StateBuilder(
|
|||
)
|
||||
}
|
||||
|
||||
fun createFeeErrorState(
|
||||
uiStateHolder: SwapStateHolder,
|
||||
quoteModel: SwapState.QuotesLoadedState,
|
||||
feeCryptoCurrencyStatus: CryptoCurrencyStatus?,
|
||||
feeError: GetFeeError,
|
||||
): SwapStateHolder {
|
||||
val fromSwapCurrencyStatus = quoteModel.fromTokenInfo.swapCurrencyStatus
|
||||
if (feeCryptoCurrencyStatus == null) return uiStateHolder
|
||||
|
||||
val notifications = notificationsFactory.getConfirmationStateNotifications(
|
||||
quoteModel = quoteModel,
|
||||
feeCryptoCurrencyStatus = feeCryptoCurrencyStatus,
|
||||
swapFee = null,
|
||||
feeError = feeError,
|
||||
appRouter = appRouter,
|
||||
)
|
||||
|
||||
return uiStateHolder.copy(
|
||||
notifications = notifications,
|
||||
swapButton = SwapButton(
|
||||
walletInteractionIcon = walletInterationIcon(fromSwapCurrencyStatus.userWallet),
|
||||
isEnabled = false,
|
||||
isHoldToConfirm = fromSwapCurrencyStatus.userWallet.isHotWallet,
|
||||
onClick = actions.onSwapClick,
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
private fun shouldShowMaxAmount(fromToken: CryptoCurrency?, toCurrency: CryptoCurrency?): Boolean {
|
||||
return !(fromToken is CryptoCurrency.Coin && fromToken.network.id == toCurrency?.network?.id)
|
||||
}
|
||||
|
|
@ -596,12 +621,15 @@ internal class StateBuilder(
|
|||
}
|
||||
|
||||
private fun isInsufficientFundsCondition(quoteModel: SwapState.QuotesLoadedState): Boolean {
|
||||
return !quoteModel.preparedSwapConfigState.isBalanceEnough &&
|
||||
quoteModel.preparedSwapConfigState.includeFeeInAmount !is IncludeFeeInAmount.Included
|
||||
return quoteModel.preparedSwapConfigState.balanceStatus is SwapBalanceStatus.InsufficientAmount
|
||||
}
|
||||
|
||||
private fun getSwapButtonEnabled(notifications: ImmutableList<NotificationUM>, priceImpact: PriceImpact): Boolean {
|
||||
return notifications.none { notification ->
|
||||
private fun getSwapButtonEnabled(
|
||||
notifications: ImmutableList<NotificationUM>,
|
||||
priceImpact: PriceImpact,
|
||||
swapFee: SwapFee?,
|
||||
): Boolean {
|
||||
return swapFee != null && notifications.none { notification ->
|
||||
notification is SwapNotificationUM.Error || notification is NotificationUM.Error ||
|
||||
notification is SwapNotificationUM.Warning.ExpressErrorWarning ||
|
||||
notification is SwapNotificationUM.Warning.ExpressGeneralError ||
|
||||
|
|
@ -618,9 +646,10 @@ internal class StateBuilder(
|
|||
swapProvider: SwapProvider,
|
||||
fromToken: TokenSwapInfo,
|
||||
toSwapCurrencyStatus: SwapCurrencyStatus?,
|
||||
includeFeeInAmount: IncludeFeeInAmount,
|
||||
balanceStatus: SwapBalanceStatus,
|
||||
expressDataError: ExpressDataError,
|
||||
needApplyFCARestrictions: Boolean,
|
||||
swapFee: SwapFee?,
|
||||
): SwapStateHolder {
|
||||
if (uiStateHolder.sendCardData !is SwapCardState.SwapCardData) return uiStateHolder
|
||||
if (uiStateHolder.receiveCardData !is SwapCardState.SwapCardData) return uiStateHolder
|
||||
|
|
@ -629,8 +658,8 @@ internal class StateBuilder(
|
|||
val notifications = notificationsFactory.getQuotesErrorStateNotifications(
|
||||
expressDataError = expressDataError,
|
||||
fromToken = fromSwapCurrencyStatus.currency,
|
||||
feeItem = uiStateHolder.fee,
|
||||
includeFeeInAmount = includeFeeInAmount,
|
||||
balanceStatus = balanceStatus,
|
||||
swapFee = swapFee,
|
||||
)
|
||||
|
||||
val providerState = getProviderStateForError(
|
||||
|
|
@ -668,7 +697,6 @@ internal class StateBuilder(
|
|||
receiveCardData = receiveCardData,
|
||||
notifications = notifications,
|
||||
permissionUM = SwapPermissionUM.Empty,
|
||||
fee = FeeItemState.Empty,
|
||||
swapButton = SwapButton(
|
||||
walletInteractionIcon = walletInterationIcon(fromSwapCurrencyStatus.userWallet),
|
||||
isEnabled = false,
|
||||
|
|
@ -740,7 +768,6 @@ internal class StateBuilder(
|
|||
),
|
||||
notifications = persistentListOf(),
|
||||
isInsufficientFunds = false,
|
||||
fee = FeeItemState.Empty,
|
||||
swapButton = SwapButton(
|
||||
walletInteractionIcon = fromSwapCurrencyStatus?.userWallet?.let(::walletInterationIcon),
|
||||
isEnabled = false,
|
||||
|
|
@ -853,38 +880,6 @@ internal class StateBuilder(
|
|||
)
|
||||
}
|
||||
|
||||
private fun createFeeState(txFeeState: TxFeeState, feeType: FeeType): FeeItemState {
|
||||
val isClickable: Boolean
|
||||
val fee = when (txFeeState) {
|
||||
TxFeeState.Empty -> return FeeItemState.Empty
|
||||
is TxFeeState.SingleFeeState -> {
|
||||
isClickable = false
|
||||
txFeeState.fee
|
||||
}
|
||||
is TxFeeState.MultipleFeeState -> {
|
||||
isClickable = true
|
||||
when (feeType) {
|
||||
FeeType.NORMAL -> {
|
||||
txFeeState.normalFee
|
||||
}
|
||||
FeeType.PRIORITY -> {
|
||||
txFeeState.priorityFee
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return FeeItemState.Content(
|
||||
feeType = feeType,
|
||||
title = resourceReference(R.string.common_network_fee_title),
|
||||
amountCrypto = fee.feeCryptoFormattedWithNative, // display fee with native as workaround for okx
|
||||
symbolCrypto = fee.cryptoSymbol,
|
||||
amountFiatFormatted = fee.feeFiatFormattedWithNative, // display fee with native as workaround for okx
|
||||
isClickable = isClickable,
|
||||
onClick = actions.onClickFee,
|
||||
)
|
||||
}
|
||||
|
||||
fun loadingPermissionState(uiState: SwapStateHolder): SwapStateHolder {
|
||||
return uiState.copy(
|
||||
swapButton = uiState.swapButton.copy(
|
||||
|
|
@ -903,6 +898,7 @@ internal class StateBuilder(
|
|||
onExploreClick: () -> Unit,
|
||||
onStatusClick: () -> Unit,
|
||||
txUrl: String,
|
||||
swapFee: SwapFee?,
|
||||
): SwapStateHolder {
|
||||
val fromSwapCurrencyStatus = requireNotNull(dataState.fromSwapCurrencyStatus)
|
||||
val toSwapCurrencyStatus = requireNotNull(dataState.toSwapCurrencyStatus)
|
||||
|
|
@ -922,11 +918,10 @@ internal class StateBuilder(
|
|||
providerName = stringReference(providerState.name),
|
||||
providerType = stringReference(providerState.type),
|
||||
shouldShowStatusButton = shouldShowStatus,
|
||||
isTransferMode = false,
|
||||
providerIcon = providerState.iconUrl,
|
||||
rate = providerState.subtitle,
|
||||
fee = dataState.selectedFee?.let { fee ->
|
||||
stringReference("${fee.feeCryptoFormattedWithNative} (${fee.feeFiatFormattedWithNative})")
|
||||
},
|
||||
fee = swapFee?.let { fee -> formatSwapFeeForSuccess(fee) },
|
||||
fromTitle = getCardAccountTitle(fromSwapCurrencyStatus.account, isFromCard = true),
|
||||
toTitle = getCardAccountTitle(toSwapCurrencyStatus.account, isFromCard = false),
|
||||
fromTokenAmount = stringReference(swapTransactionState.fromAmount.orEmpty()),
|
||||
|
|
@ -967,6 +962,7 @@ internal class StateBuilder(
|
|||
providerName = stringReference(providerState.name),
|
||||
providerType = stringReference(providerState.type),
|
||||
shouldShowStatusButton = false,
|
||||
isTransferMode = false,
|
||||
providerIcon = providerState.iconUrl,
|
||||
rate = providerState.subtitle,
|
||||
fee = TextReference.EMPTY,
|
||||
|
|
@ -1111,57 +1107,18 @@ internal class StateBuilder(
|
|||
)
|
||||
}
|
||||
|
||||
fun showSelectFeeBottomSheet(
|
||||
uiState: SwapStateHolder,
|
||||
selectedFee: FeeType,
|
||||
txFeeState: TxFeeState.MultipleFeeState,
|
||||
readMoreUrl: String,
|
||||
onDismiss: () -> Unit,
|
||||
): SwapStateHolder {
|
||||
val config = ChooseFeeBottomSheetConfig(
|
||||
selectedFee = selectedFee,
|
||||
onSelectFeeType = { feeType ->
|
||||
val selectedItem = when (feeType) {
|
||||
FeeType.NORMAL -> txFeeState.normalFee
|
||||
FeeType.PRIORITY -> txFeeState.priorityFee
|
||||
}
|
||||
actions.onSelectFeeType.invoke(selectedItem)
|
||||
},
|
||||
readMoreUrl = readMoreUrl,
|
||||
feeItems = txFeeState.toFeeItemState(),
|
||||
readMore = resourceReference(R.string.common_read_more),
|
||||
onReadMoreClick = actions.onLinkClick,
|
||||
)
|
||||
return uiState.copy(
|
||||
bottomSheetConfig = TangemBottomSheetConfig(
|
||||
isShown = true,
|
||||
onDismissRequest = onDismiss,
|
||||
content = config,
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
private fun TxFeeState.MultipleFeeState.toFeeItemState(): ImmutableList<FeeItemState.Content> {
|
||||
return listOf(
|
||||
FeeItemState.Content(
|
||||
feeType = this.normalFee.feeType,
|
||||
title = resourceReference(R.string.common_network_fee_title),
|
||||
amountCrypto = this.normalFee.feeCryptoFormattedWithNative,
|
||||
symbolCrypto = this.normalFee.cryptoSymbol,
|
||||
amountFiatFormatted = this.normalFee.feeFiatFormattedWithNative,
|
||||
isClickable = true,
|
||||
onClick = {},
|
||||
),
|
||||
FeeItemState.Content(
|
||||
feeType = this.priorityFee.feeType,
|
||||
title = resourceReference(R.string.common_network_fee_title),
|
||||
amountCrypto = this.priorityFee.feeCryptoFormattedWithNative,
|
||||
symbolCrypto = this.priorityFee.cryptoSymbol,
|
||||
amountFiatFormatted = this.priorityFee.feeFiatFormattedWithNative,
|
||||
isClickable = true,
|
||||
onClick = {},
|
||||
),
|
||||
).toImmutableList()
|
||||
private fun formatSwapFeeForSuccess(swapFee: SwapFee): TextReference {
|
||||
val feeAmount = swapFee.fee.amount
|
||||
val totalFeeValue = (feeAmount.value ?: BigDecimal.ZERO) + swapFee.otherNativeFee
|
||||
val cryptoFormatted = totalFeeValue.format {
|
||||
crypto(symbol = feeAmount.currencySymbol, decimals = feeAmount.decimals)
|
||||
}
|
||||
val appCurrency = appCurrencyProvider()
|
||||
val fiatRate = swapFee.selectedFeeToken.value.fiatRate
|
||||
val fiatFormatted = fiatRate?.multiply(totalFeeValue).format {
|
||||
fiat(fiatCurrencyCode = appCurrency.code, fiatCurrencySymbol = appCurrency.symbol)
|
||||
}
|
||||
return stringReference("$cryptoFormatted ($fiatFormatted)")
|
||||
}
|
||||
|
||||
private fun Map.Entry<SwapProvider, SwapState>.convertToProviderBottomSheetState(
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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 = {},
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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 = {},
|
||||
),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
|
@ -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(
|
||||
|
|
|
|||
|
|
@ -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`() {
|
||||
|
|
|
|||
|
|
@ -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`() {
|
||||
|
|
|
|||
|
|
@ -1,847 +0,0 @@
|
|||
package com.tangem.feature.swap
|
||||
|
||||
import com.google.common.truth.Truth.assertThat
|
||||
import com.tangem.common.routing.AppRouter
|
||||
import com.tangem.domain.appcurrency.model.AppCurrency
|
||||
import com.tangem.domain.models.wallet.UserWallet
|
||||
import com.tangem.domain.models.wallet.UserWalletId
|
||||
import com.tangem.domain.swap.models.SwapCurrencyStatus
|
||||
import com.tangem.domain.transaction.usecase.gasless.IsGaslessFeeSupportedForNetwork
|
||||
import com.tangem.feature.swap.domain.models.ExpressDataError
|
||||
import com.tangem.feature.swap.domain.models.SwapAmount
|
||||
import com.tangem.feature.swap.domain.models.domain.*
|
||||
import com.tangem.feature.swap.domain.models.ui.*
|
||||
import com.tangem.feature.swap.models.*
|
||||
import com.tangem.feature.swap.models.states.FeeItemState
|
||||
import com.tangem.feature.swap.models.states.ProviderState
|
||||
import com.tangem.feature.swap.ui.StateBuilder
|
||||
import com.tangem.features.swap.SwapFeatureToggles
|
||||
import com.tangem.utils.Provider
|
||||
import io.mockk.every
|
||||
import io.mockk.mockk
|
||||
import org.junit.jupiter.api.BeforeEach
|
||||
import org.junit.jupiter.api.Nested
|
||||
import org.junit.jupiter.api.Test
|
||||
import java.math.BigDecimal
|
||||
|
||||
internal class StateBuilderQuotesTest {
|
||||
|
||||
private val actions: UiActions = mockk(relaxed = true)
|
||||
private val isBalanceHiddenProvider: Provider<Boolean> = mockk()
|
||||
private val appCurrencyProvider: Provider<AppCurrency> = mockk()
|
||||
private val isAccountsModeProvider: Provider<Boolean> = mockk()
|
||||
private val isGaslessFeeSupportedForNetwork: IsGaslessFeeSupportedForNetwork = mockk()
|
||||
private val swapFeatureToggles: SwapFeatureToggles = mockk(relaxed = true)
|
||||
private val appRouter: AppRouter = mockk()
|
||||
|
||||
private lateinit var sut: StateBuilder
|
||||
|
||||
private val userWalletId = UserWalletId("aabbccdd")
|
||||
private val coldWallet: UserWallet.Cold = mockk(relaxed = true) {
|
||||
every { walletId } returns userWalletId
|
||||
}
|
||||
private val hotWallet: UserWallet.Hot = mockk(relaxed = true) {
|
||||
every { walletId } returns userWalletId
|
||||
}
|
||||
|
||||
private val emptyAmountState = SwapState.EmptyAmountState(
|
||||
zeroAmountEquivalent = com.tangem.core.ui.extensions.stringReference("$0.00"),
|
||||
)
|
||||
|
||||
@BeforeEach
|
||||
fun setup() {
|
||||
every { isBalanceHiddenProvider() } returns false
|
||||
every { appCurrencyProvider() } returns AppCurrency.Default
|
||||
every { isAccountsModeProvider() } returns false
|
||||
every { isGaslessFeeSupportedForNetwork(any()) } returns false
|
||||
|
||||
sut = StateBuilder(
|
||||
actions = actions,
|
||||
isBalanceHiddenProvider = isBalanceHiddenProvider,
|
||||
appCurrencyProvider = appCurrencyProvider,
|
||||
isAccountsModeProvider = isAccountsModeProvider,
|
||||
isGaslessFeeSupportedForNetwork = isGaslessFeeSupportedForNetwork,
|
||||
swapFeatureToggles = swapFeatureToggles,
|
||||
appRouter = appRouter,
|
||||
)
|
||||
}
|
||||
|
||||
// region createQuotesLoadingState
|
||||
|
||||
@Nested
|
||||
inner class CreateQuotesLoadingState {
|
||||
|
||||
@Test
|
||||
fun `GIVEN uiState has Empty sendCard WHEN called THEN returns uiState unchanged`() {
|
||||
val loadingState = sut.createInitialLoadingState()
|
||||
val fromStatus = buildSwapCurrencyStatus(coldWallet)
|
||||
val toStatus = buildSwapCurrencyStatus(coldWallet)
|
||||
|
||||
val result = sut.createQuotesLoadingState(
|
||||
fromSwapCurrencyStatus = fromStatus,
|
||||
toSwapCurrencyStatus = toStatus,
|
||||
uiStateHolder = loadingState,
|
||||
)
|
||||
|
||||
assertThat(result).isSameInstanceAs(loadingState)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `GIVEN valid SwapCardData state WHEN called THEN providerState is Loading`() {
|
||||
val baseState = buildReadyState(coldWallet)
|
||||
val fromStatus = buildSwapCurrencyStatus(coldWallet)
|
||||
val toStatus = buildSwapCurrencyStatus(coldWallet)
|
||||
|
||||
val result = sut.createQuotesLoadingState(
|
||||
fromSwapCurrencyStatus = fromStatus,
|
||||
toSwapCurrencyStatus = toStatus,
|
||||
uiStateHolder = baseState,
|
||||
)
|
||||
|
||||
assertThat(result.providerState).isInstanceOf(ProviderState.Loading::class.java)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `GIVEN valid state WHEN called THEN changeCardsButtonState is UPDATE_IN_PROGRESS`() {
|
||||
val baseState = buildReadyState(coldWallet)
|
||||
val fromStatus = buildSwapCurrencyStatus(coldWallet)
|
||||
val toStatus = buildSwapCurrencyStatus(coldWallet)
|
||||
|
||||
val result = sut.createQuotesLoadingState(
|
||||
fromSwapCurrencyStatus = fromStatus,
|
||||
toSwapCurrencyStatus = toStatus,
|
||||
uiStateHolder = baseState,
|
||||
)
|
||||
|
||||
assertThat(result.changeCardsButtonState).isEqualTo(ChangeCardsButtonState.UPDATE_IN_PROGRESS)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `GIVEN valid state WHEN called THEN swapButton is disabled`() {
|
||||
val baseState = buildReadyState(coldWallet)
|
||||
val fromStatus = buildSwapCurrencyStatus(coldWallet)
|
||||
val toStatus = buildSwapCurrencyStatus(coldWallet)
|
||||
|
||||
val result = sut.createQuotesLoadingState(
|
||||
fromSwapCurrencyStatus = fromStatus,
|
||||
toSwapCurrencyStatus = toStatus,
|
||||
uiStateHolder = baseState,
|
||||
)
|
||||
|
||||
assertThat(result.swapButton.isEnabled).isFalse()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `GIVEN valid state WHEN called THEN fee is Empty`() {
|
||||
val baseState = buildReadyState(coldWallet)
|
||||
val fromStatus = buildSwapCurrencyStatus(coldWallet)
|
||||
val toStatus = buildSwapCurrencyStatus(coldWallet)
|
||||
|
||||
val result = sut.createQuotesLoadingState(
|
||||
fromSwapCurrencyStatus = fromStatus,
|
||||
toSwapCurrencyStatus = toStatus,
|
||||
uiStateHolder = baseState,
|
||||
)
|
||||
|
||||
assertThat(result.fee).isInstanceOf(FeeItemState.Empty::class.java)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `GIVEN valid state WHEN called THEN notifications is cleared`() {
|
||||
val baseState = buildReadyState(coldWallet)
|
||||
val fromStatus = buildSwapCurrencyStatus(coldWallet)
|
||||
val toStatus = buildSwapCurrencyStatus(coldWallet)
|
||||
|
||||
val result = sut.createQuotesLoadingState(
|
||||
fromSwapCurrencyStatus = fromStatus,
|
||||
toSwapCurrencyStatus = toStatus,
|
||||
uiStateHolder = baseState,
|
||||
)
|
||||
|
||||
assertThat(result.notifications).isEmpty()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `GIVEN hot wallet WHEN called THEN swapButton isHoldToConfirm is true`() {
|
||||
val baseState = buildReadyState(hotWallet)
|
||||
val fromStatus = buildSwapCurrencyStatus(hotWallet)
|
||||
val toStatus = buildSwapCurrencyStatus(hotWallet)
|
||||
|
||||
val result = sut.createQuotesLoadingState(
|
||||
fromSwapCurrencyStatus = fromStatus,
|
||||
toSwapCurrencyStatus = toStatus,
|
||||
uiStateHolder = baseState,
|
||||
)
|
||||
|
||||
assertThat(result.swapButton.isHoldToConfirm).isTrue()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `GIVEN valid state WHEN called THEN receiveCardData amountTextFieldValue is null`() {
|
||||
val baseState = buildReadyState(coldWallet)
|
||||
val fromStatus = buildSwapCurrencyStatus(coldWallet)
|
||||
val toStatus = buildSwapCurrencyStatus(coldWallet)
|
||||
|
||||
val result = sut.createQuotesLoadingState(
|
||||
fromSwapCurrencyStatus = fromStatus,
|
||||
toSwapCurrencyStatus = toStatus,
|
||||
uiStateHolder = baseState,
|
||||
)
|
||||
|
||||
val receiveCard = result.receiveCardData as? SwapCardState.SwapCardData
|
||||
assertThat(receiveCard?.amountTextFieldValue).isNull()
|
||||
}
|
||||
}
|
||||
|
||||
// endregion
|
||||
|
||||
// region createQuotesLoadedState
|
||||
|
||||
@Nested
|
||||
inner class CreateQuotesLoadedState {
|
||||
|
||||
@Test
|
||||
fun `GIVEN uiState has Empty sendCard WHEN called THEN returns uiState unchanged`() {
|
||||
val loadingState = sut.createInitialLoadingState()
|
||||
val quoteModel = buildQuoteModel(coldWallet, isBalanceEnough = true)
|
||||
val swapProvider = buildSwapProvider()
|
||||
|
||||
val result = sut.createQuotesLoadedState(
|
||||
uiStateHolder = loadingState,
|
||||
quoteModel = quoteModel,
|
||||
feeCryptoCurrencyStatus = null,
|
||||
swapProvider = swapProvider,
|
||||
bestRatedProviderId = "provider-id",
|
||||
isNeedBestRateBadge = false,
|
||||
selectedFeeType = FeeType.NORMAL,
|
||||
needApplyFCARestrictions = false,
|
||||
hideFee = false,
|
||||
)
|
||||
|
||||
assertThat(result).isSameInstanceAs(loadingState)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `GIVEN valid state with hideFee true WHEN called THEN fee is Empty`() {
|
||||
val baseState = buildReadyState(coldWallet)
|
||||
val quoteModel = buildQuoteModel(coldWallet, isBalanceEnough = true)
|
||||
val swapProvider = buildSwapProvider()
|
||||
|
||||
val result = sut.createQuotesLoadedState(
|
||||
uiStateHolder = baseState,
|
||||
quoteModel = quoteModel,
|
||||
feeCryptoCurrencyStatus = null,
|
||||
swapProvider = swapProvider,
|
||||
bestRatedProviderId = "provider-id",
|
||||
isNeedBestRateBadge = false,
|
||||
selectedFeeType = FeeType.NORMAL,
|
||||
needApplyFCARestrictions = false,
|
||||
hideFee = true,
|
||||
)
|
||||
|
||||
assertThat(result.fee).isInstanceOf(FeeItemState.Empty::class.java)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `GIVEN valid state with hideFee false and single fee WHEN called THEN fee is Content`() {
|
||||
val baseState = buildReadyState(coldWallet)
|
||||
val quoteModel = buildQuoteModel(
|
||||
userWallet = coldWallet,
|
||||
isBalanceEnough = true,
|
||||
txFeeState = TxFeeState.SingleFeeState(fee = buildTxFeeLegacy(FeeType.NORMAL)),
|
||||
)
|
||||
val swapProvider = buildSwapProvider()
|
||||
|
||||
val result = sut.createQuotesLoadedState(
|
||||
uiStateHolder = baseState,
|
||||
quoteModel = quoteModel,
|
||||
feeCryptoCurrencyStatus = null,
|
||||
swapProvider = swapProvider,
|
||||
bestRatedProviderId = "provider-id",
|
||||
isNeedBestRateBadge = false,
|
||||
selectedFeeType = FeeType.NORMAL,
|
||||
needApplyFCARestrictions = false,
|
||||
hideFee = false,
|
||||
)
|
||||
|
||||
assertThat(result.fee).isInstanceOf(FeeItemState.Content::class.java)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `GIVEN valid state with sufficient balance WHEN called THEN isInsufficientFunds is false`() {
|
||||
val baseState = buildReadyState(coldWallet)
|
||||
val quoteModel = buildQuoteModel(coldWallet, isBalanceEnough = true)
|
||||
val swapProvider = buildSwapProvider()
|
||||
|
||||
val result = sut.createQuotesLoadedState(
|
||||
uiStateHolder = baseState,
|
||||
quoteModel = quoteModel,
|
||||
feeCryptoCurrencyStatus = null,
|
||||
swapProvider = swapProvider,
|
||||
bestRatedProviderId = "provider-id",
|
||||
isNeedBestRateBadge = false,
|
||||
selectedFeeType = FeeType.NORMAL,
|
||||
needApplyFCARestrictions = false,
|
||||
hideFee = false,
|
||||
)
|
||||
|
||||
assertThat(result.isInsufficientFunds).isFalse()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `GIVEN valid state with insufficient balance WHEN called THEN isInsufficientFunds is true`() {
|
||||
val baseState = buildReadyState(coldWallet)
|
||||
val quoteModel = buildQuoteModel(
|
||||
coldWallet,
|
||||
isBalanceEnough = false,
|
||||
includeFeeInAmount = IncludeFeeInAmount.Excluded,
|
||||
)
|
||||
val swapProvider = buildSwapProvider()
|
||||
|
||||
val result = sut.createQuotesLoadedState(
|
||||
uiStateHolder = baseState,
|
||||
quoteModel = quoteModel,
|
||||
feeCryptoCurrencyStatus = null,
|
||||
swapProvider = swapProvider,
|
||||
bestRatedProviderId = "provider-id",
|
||||
isNeedBestRateBadge = false,
|
||||
selectedFeeType = FeeType.NORMAL,
|
||||
needApplyFCARestrictions = false,
|
||||
hideFee = false,
|
||||
)
|
||||
|
||||
assertThat(result.isInsufficientFunds).isTrue()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `GIVEN valid state WHEN called THEN changeCardsButtonState is ENABLED`() {
|
||||
val baseState = buildReadyState(coldWallet)
|
||||
val quoteModel = buildQuoteModel(coldWallet, isBalanceEnough = true)
|
||||
val swapProvider = buildSwapProvider()
|
||||
|
||||
val result = sut.createQuotesLoadedState(
|
||||
uiStateHolder = baseState,
|
||||
quoteModel = quoteModel,
|
||||
feeCryptoCurrencyStatus = null,
|
||||
swapProvider = swapProvider,
|
||||
bestRatedProviderId = "provider-id",
|
||||
isNeedBestRateBadge = false,
|
||||
selectedFeeType = FeeType.NORMAL,
|
||||
needApplyFCARestrictions = false,
|
||||
hideFee = false,
|
||||
)
|
||||
|
||||
assertThat(result.changeCardsButtonState).isEqualTo(ChangeCardsButtonState.ENABLED)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `GIVEN valid state with hot wallet WHEN called THEN swapButton isHoldToConfirm is true`() {
|
||||
val baseState = buildReadyState(hotWallet)
|
||||
val quoteModel = buildQuoteModel(hotWallet, isBalanceEnough = true)
|
||||
val swapProvider = buildSwapProvider()
|
||||
|
||||
val result = sut.createQuotesLoadedState(
|
||||
uiStateHolder = baseState,
|
||||
quoteModel = quoteModel,
|
||||
feeCryptoCurrencyStatus = null,
|
||||
swapProvider = swapProvider,
|
||||
bestRatedProviderId = "provider-id",
|
||||
isNeedBestRateBadge = false,
|
||||
selectedFeeType = FeeType.NORMAL,
|
||||
needApplyFCARestrictions = false,
|
||||
hideFee = false,
|
||||
)
|
||||
|
||||
assertThat(result.swapButton.isHoldToConfirm).isTrue()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `GIVEN provider with termsOfUse WHEN called THEN tosState has tosLink`() {
|
||||
val baseState = buildReadyState(coldWallet)
|
||||
val quoteModel = buildQuoteModel(coldWallet, isBalanceEnough = true)
|
||||
val swapProvider = buildSwapProvider(termsOfUse = "https://example.com/tos")
|
||||
|
||||
val result = sut.createQuotesLoadedState(
|
||||
uiStateHolder = baseState,
|
||||
quoteModel = quoteModel,
|
||||
feeCryptoCurrencyStatus = null,
|
||||
swapProvider = swapProvider,
|
||||
bestRatedProviderId = "provider-id",
|
||||
isNeedBestRateBadge = false,
|
||||
selectedFeeType = FeeType.NORMAL,
|
||||
needApplyFCARestrictions = false,
|
||||
hideFee = false,
|
||||
)
|
||||
|
||||
assertThat(result.tosState?.tosLink).isNotNull()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `GIVEN provider without termsOfUse WHEN called THEN tosState has null tosLink`() {
|
||||
val baseState = buildReadyState(coldWallet)
|
||||
val quoteModel = buildQuoteModel(coldWallet, isBalanceEnough = true)
|
||||
val swapProvider = buildSwapProvider(termsOfUse = null)
|
||||
|
||||
val result = sut.createQuotesLoadedState(
|
||||
uiStateHolder = baseState,
|
||||
quoteModel = quoteModel,
|
||||
feeCryptoCurrencyStatus = null,
|
||||
swapProvider = swapProvider,
|
||||
bestRatedProviderId = "provider-id",
|
||||
isNeedBestRateBadge = false,
|
||||
selectedFeeType = FeeType.NORMAL,
|
||||
needApplyFCARestrictions = false,
|
||||
hideFee = false,
|
||||
)
|
||||
|
||||
assertThat(result.tosState?.tosLink).isNull()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `GIVEN no blocking notifications WHEN called THEN swapButton is enabled`() {
|
||||
val baseState = buildReadyState(coldWallet)
|
||||
val quoteModel = buildQuoteModel(coldWallet, isBalanceEnough = true)
|
||||
val swapProvider = buildSwapProvider()
|
||||
|
||||
val result = sut.createQuotesLoadedState(
|
||||
uiStateHolder = baseState,
|
||||
quoteModel = quoteModel,
|
||||
feeCryptoCurrencyStatus = null,
|
||||
swapProvider = swapProvider,
|
||||
bestRatedProviderId = "provider-id",
|
||||
isNeedBestRateBadge = false,
|
||||
selectedFeeType = FeeType.NORMAL,
|
||||
needApplyFCARestrictions = false,
|
||||
hideFee = false,
|
||||
)
|
||||
|
||||
assertThat(result.swapButton.isEnabled).isTrue()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `GIVEN multiple fee state WHEN called THEN fee is Content with isClickable true`() {
|
||||
val baseState = buildReadyState(coldWallet)
|
||||
val quoteModel = buildQuoteModel(
|
||||
userWallet = coldWallet,
|
||||
isBalanceEnough = true,
|
||||
txFeeState = TxFeeState.MultipleFeeState(
|
||||
normalFee = buildTxFeeLegacy(FeeType.NORMAL),
|
||||
priorityFee = buildTxFeeLegacy(FeeType.PRIORITY),
|
||||
),
|
||||
)
|
||||
val swapProvider = buildSwapProvider()
|
||||
|
||||
val result = sut.createQuotesLoadedState(
|
||||
uiStateHolder = baseState,
|
||||
quoteModel = quoteModel,
|
||||
feeCryptoCurrencyStatus = null,
|
||||
swapProvider = swapProvider,
|
||||
bestRatedProviderId = "provider-id",
|
||||
isNeedBestRateBadge = false,
|
||||
selectedFeeType = FeeType.NORMAL,
|
||||
needApplyFCARestrictions = false,
|
||||
hideFee = false,
|
||||
)
|
||||
|
||||
val feeContent = result.fee as? FeeItemState.Content
|
||||
assertThat(feeContent?.isClickable).isTrue()
|
||||
}
|
||||
}
|
||||
|
||||
// endregion
|
||||
|
||||
// region createQuotesErrorState
|
||||
|
||||
@Nested
|
||||
inner class CreateQuotesErrorState {
|
||||
|
||||
@Test
|
||||
fun `GIVEN uiState has Empty sendCard WHEN called THEN returns uiState unchanged`() {
|
||||
val loadingState = sut.createInitialLoadingState()
|
||||
val fromStatus = buildSwapCurrencyStatus(coldWallet)
|
||||
val fromTokenInfo = TokenSwapInfo(
|
||||
tokenAmount = buildSwapAmount(),
|
||||
amountFiat = BigDecimal.ZERO,
|
||||
swapCurrencyStatus = fromStatus,
|
||||
)
|
||||
val swapProvider = buildSwapProvider()
|
||||
|
||||
val result = sut.createQuotesErrorState(
|
||||
uiStateHolder = loadingState,
|
||||
swapProvider = swapProvider,
|
||||
fromToken = fromTokenInfo,
|
||||
toSwapCurrencyStatus = null,
|
||||
includeFeeInAmount = IncludeFeeInAmount.Excluded,
|
||||
expressDataError = ExpressDataError.UnknownError,
|
||||
needApplyFCARestrictions = false,
|
||||
)
|
||||
|
||||
assertThat(result).isSameInstanceAs(loadingState)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `GIVEN valid state WHEN called THEN swapButton is disabled`() {
|
||||
val baseState = buildReadyState(coldWallet)
|
||||
val fromStatus = buildSwapCurrencyStatus(coldWallet)
|
||||
val fromTokenInfo = buildTokenSwapInfo(fromStatus)
|
||||
val swapProvider = buildSwapProvider()
|
||||
|
||||
val result = sut.createQuotesErrorState(
|
||||
uiStateHolder = baseState,
|
||||
swapProvider = swapProvider,
|
||||
fromToken = fromTokenInfo,
|
||||
toSwapCurrencyStatus = null,
|
||||
includeFeeInAmount = IncludeFeeInAmount.Excluded,
|
||||
expressDataError = ExpressDataError.UnknownError,
|
||||
needApplyFCARestrictions = false,
|
||||
)
|
||||
|
||||
assertThat(result.swapButton.isEnabled).isFalse()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `GIVEN valid state WHEN called THEN fee is Empty`() {
|
||||
val baseState = buildReadyState(coldWallet)
|
||||
val fromStatus = buildSwapCurrencyStatus(coldWallet)
|
||||
val fromTokenInfo = buildTokenSwapInfo(fromStatus)
|
||||
val swapProvider = buildSwapProvider()
|
||||
|
||||
val result = sut.createQuotesErrorState(
|
||||
uiStateHolder = baseState,
|
||||
swapProvider = swapProvider,
|
||||
fromToken = fromTokenInfo,
|
||||
toSwapCurrencyStatus = null,
|
||||
includeFeeInAmount = IncludeFeeInAmount.Excluded,
|
||||
expressDataError = ExpressDataError.UnknownError,
|
||||
needApplyFCARestrictions = false,
|
||||
)
|
||||
|
||||
assertThat(result.fee).isInstanceOf(FeeItemState.Empty::class.java)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `GIVEN valid state WHEN called THEN permissionUM is Empty`() {
|
||||
val baseState = buildReadyState(coldWallet)
|
||||
val fromStatus = buildSwapCurrencyStatus(coldWallet)
|
||||
val fromTokenInfo = buildTokenSwapInfo(fromStatus)
|
||||
val swapProvider = buildSwapProvider()
|
||||
|
||||
val result = sut.createQuotesErrorState(
|
||||
uiStateHolder = baseState,
|
||||
swapProvider = swapProvider,
|
||||
fromToken = fromTokenInfo,
|
||||
toSwapCurrencyStatus = null,
|
||||
includeFeeInAmount = IncludeFeeInAmount.Excluded,
|
||||
expressDataError = ExpressDataError.UnknownError,
|
||||
needApplyFCARestrictions = false,
|
||||
)
|
||||
|
||||
assertThat(result.permissionUM).isEqualTo(SwapPermissionUM.Empty)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `GIVEN toSwapCurrencyStatus null WHEN called THEN receiveCardData is Empty`() {
|
||||
val baseState = buildReadyState(coldWallet)
|
||||
val fromStatus = buildSwapCurrencyStatus(coldWallet)
|
||||
val fromTokenInfo = buildTokenSwapInfo(fromStatus)
|
||||
val swapProvider = buildSwapProvider()
|
||||
|
||||
val result = sut.createQuotesErrorState(
|
||||
uiStateHolder = baseState,
|
||||
swapProvider = swapProvider,
|
||||
fromToken = fromTokenInfo,
|
||||
toSwapCurrencyStatus = null,
|
||||
includeFeeInAmount = IncludeFeeInAmount.Excluded,
|
||||
expressDataError = ExpressDataError.UnknownError,
|
||||
needApplyFCARestrictions = false,
|
||||
)
|
||||
|
||||
assertThat(result.receiveCardData).isInstanceOf(SwapCardState.Empty::class.java)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `GIVEN toSwapCurrencyStatus non-null WHEN called THEN receiveCardData is SwapCardData`() {
|
||||
val baseState = buildReadyState(coldWallet)
|
||||
val fromStatus = buildSwapCurrencyStatus(coldWallet)
|
||||
val toStatus = buildSwapCurrencyStatus(coldWallet)
|
||||
val fromTokenInfo = buildTokenSwapInfo(fromStatus)
|
||||
val swapProvider = buildSwapProvider()
|
||||
|
||||
val result = sut.createQuotesErrorState(
|
||||
uiStateHolder = baseState,
|
||||
swapProvider = swapProvider,
|
||||
fromToken = fromTokenInfo,
|
||||
toSwapCurrencyStatus = toStatus,
|
||||
includeFeeInAmount = IncludeFeeInAmount.Excluded,
|
||||
expressDataError = ExpressDataError.UnknownError,
|
||||
needApplyFCARestrictions = false,
|
||||
)
|
||||
|
||||
assertThat(result.receiveCardData).isInstanceOf(SwapCardState.SwapCardData::class.java)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `GIVEN ExchangeTooSmallAmountError WHEN called THEN providerState is Content`() {
|
||||
val baseState = buildReadyState(coldWallet)
|
||||
val fromStatus = buildSwapCurrencyStatus(coldWallet)
|
||||
val fromTokenInfo = buildTokenSwapInfo(fromStatus)
|
||||
val swapProvider = buildSwapProvider()
|
||||
|
||||
val result = sut.createQuotesErrorState(
|
||||
uiStateHolder = baseState,
|
||||
swapProvider = swapProvider,
|
||||
fromToken = fromTokenInfo,
|
||||
toSwapCurrencyStatus = null,
|
||||
includeFeeInAmount = IncludeFeeInAmount.Excluded,
|
||||
expressDataError = ExpressDataError.ExchangeTooSmallAmountError(
|
||||
amount = buildSwapAmount(),
|
||||
code = 100,
|
||||
),
|
||||
needApplyFCARestrictions = false,
|
||||
)
|
||||
|
||||
assertThat(result.providerState).isInstanceOf(ProviderState.Content::class.java)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `GIVEN UnknownError WHEN called THEN providerState is Empty`() {
|
||||
val baseState = buildReadyState(coldWallet)
|
||||
val fromStatus = buildSwapCurrencyStatus(coldWallet)
|
||||
val fromTokenInfo = buildTokenSwapInfo(fromStatus)
|
||||
val swapProvider = buildSwapProvider()
|
||||
|
||||
val result = sut.createQuotesErrorState(
|
||||
uiStateHolder = baseState,
|
||||
swapProvider = swapProvider,
|
||||
fromToken = fromTokenInfo,
|
||||
toSwapCurrencyStatus = null,
|
||||
includeFeeInAmount = IncludeFeeInAmount.Excluded,
|
||||
expressDataError = ExpressDataError.UnknownError,
|
||||
needApplyFCARestrictions = false,
|
||||
)
|
||||
|
||||
assertThat(result.providerState).isInstanceOf(ProviderState.Empty::class.java)
|
||||
}
|
||||
}
|
||||
|
||||
// endregion
|
||||
|
||||
// region createQuotesEmptyAmountState
|
||||
|
||||
@Nested
|
||||
inner class CreateQuotesEmptyAmountState {
|
||||
|
||||
@Test
|
||||
fun `GIVEN uiState has Empty sendCard WHEN called THEN returns uiState unchanged`() {
|
||||
val loadingState = sut.createInitialLoadingState()
|
||||
|
||||
val result = sut.createQuotesEmptyAmountState(
|
||||
uiStateHolder = loadingState,
|
||||
emptyAmountState = emptyAmountState,
|
||||
fromSwapCurrencyStatus = null,
|
||||
)
|
||||
|
||||
assertThat(result).isSameInstanceAs(loadingState)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `GIVEN valid SwapCardData state WHEN called THEN swapButton is disabled`() {
|
||||
val baseState = buildReadyState(coldWallet)
|
||||
|
||||
val result = sut.createQuotesEmptyAmountState(
|
||||
uiStateHolder = baseState,
|
||||
emptyAmountState = emptyAmountState,
|
||||
fromSwapCurrencyStatus = null,
|
||||
)
|
||||
|
||||
assertThat(result.swapButton.isEnabled).isFalse()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `GIVEN valid state WHEN called THEN notifications is empty`() {
|
||||
val baseState = buildReadyState(coldWallet)
|
||||
|
||||
val result = sut.createQuotesEmptyAmountState(
|
||||
uiStateHolder = baseState,
|
||||
emptyAmountState = emptyAmountState,
|
||||
fromSwapCurrencyStatus = null,
|
||||
)
|
||||
|
||||
assertThat(result.notifications).isEmpty()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `GIVEN valid state WHEN called THEN isInsufficientFunds is false`() {
|
||||
val baseState = buildReadyState(coldWallet)
|
||||
|
||||
val result = sut.createQuotesEmptyAmountState(
|
||||
uiStateHolder = baseState,
|
||||
emptyAmountState = emptyAmountState,
|
||||
fromSwapCurrencyStatus = null,
|
||||
)
|
||||
|
||||
assertThat(result.isInsufficientFunds).isFalse()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `GIVEN valid state WHEN called THEN fee is Empty`() {
|
||||
val baseState = buildReadyState(coldWallet)
|
||||
|
||||
val result = sut.createQuotesEmptyAmountState(
|
||||
uiStateHolder = baseState,
|
||||
emptyAmountState = emptyAmountState,
|
||||
fromSwapCurrencyStatus = null,
|
||||
)
|
||||
|
||||
assertThat(result.fee).isInstanceOf(FeeItemState.Empty::class.java)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `GIVEN valid state WHEN called THEN changeCardsButtonState is ENABLED`() {
|
||||
val baseState = buildReadyState(coldWallet)
|
||||
|
||||
val result = sut.createQuotesEmptyAmountState(
|
||||
uiStateHolder = baseState,
|
||||
emptyAmountState = emptyAmountState,
|
||||
fromSwapCurrencyStatus = null,
|
||||
)
|
||||
|
||||
assertThat(result.changeCardsButtonState).isEqualTo(ChangeCardsButtonState.ENABLED)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `GIVEN valid state WHEN called THEN providerState is Empty`() {
|
||||
val baseState = buildReadyState(coldWallet)
|
||||
|
||||
val result = sut.createQuotesEmptyAmountState(
|
||||
uiStateHolder = baseState,
|
||||
emptyAmountState = emptyAmountState,
|
||||
fromSwapCurrencyStatus = null,
|
||||
)
|
||||
|
||||
assertThat(result.providerState).isInstanceOf(ProviderState.Empty::class.java)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `GIVEN valid state WHEN called THEN receiveCard amountTextFieldValue is 0`() {
|
||||
val baseState = buildReadyState(coldWallet)
|
||||
|
||||
val result = sut.createQuotesEmptyAmountState(
|
||||
uiStateHolder = baseState,
|
||||
emptyAmountState = emptyAmountState,
|
||||
fromSwapCurrencyStatus = null,
|
||||
)
|
||||
|
||||
val receiveCard = result.receiveCardData as? SwapCardState.SwapCardData
|
||||
assertThat(receiveCard?.amountTextFieldValue?.text).isEqualTo("0")
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `GIVEN fromSwapCurrencyStatus with hot wallet WHEN called THEN swapButton isHoldToConfirm is true`() {
|
||||
val baseState = buildReadyState(hotWallet)
|
||||
val fromStatus = buildSwapCurrencyStatus(hotWallet)
|
||||
|
||||
val result = sut.createQuotesEmptyAmountState(
|
||||
uiStateHolder = baseState,
|
||||
emptyAmountState = emptyAmountState,
|
||||
fromSwapCurrencyStatus = fromStatus,
|
||||
)
|
||||
|
||||
assertThat(result.swapButton.isHoldToConfirm).isTrue()
|
||||
}
|
||||
}
|
||||
|
||||
// endregion
|
||||
|
||||
// --- Helpers ---
|
||||
|
||||
private fun buildReadyState(userWallet: UserWallet): SwapStateHolder {
|
||||
val fromStatus = buildSwapCurrencyStatus(userWallet)
|
||||
val toStatus = buildSwapCurrencyStatus(userWallet)
|
||||
return sut.createInitialReadyState(
|
||||
uiStateHolder = sut.createInitialLoadingState(),
|
||||
emptyAmountState = emptyAmountState,
|
||||
fromSwapCurrencyStatus = fromStatus,
|
||||
toSwapCurrencyStatus = toStatus,
|
||||
)
|
||||
}
|
||||
|
||||
private fun buildQuoteModel(
|
||||
userWallet: UserWallet,
|
||||
isBalanceEnough: Boolean,
|
||||
includeFeeInAmount: IncludeFeeInAmount = IncludeFeeInAmount.Excluded,
|
||||
txFeeState: TxFeeState = TxFeeState.Empty,
|
||||
): SwapState.QuotesLoadedState {
|
||||
val fromStatus = buildSwapCurrencyStatus(userWallet)
|
||||
val toStatus = buildSwapCurrencyStatus(userWallet)
|
||||
|
||||
val fromTokenInfo = TokenSwapInfo(
|
||||
tokenAmount = buildSwapAmount(),
|
||||
amountFiat = BigDecimal("100.00"),
|
||||
swapCurrencyStatus = fromStatus,
|
||||
)
|
||||
val toTokenInfo = TokenSwapInfo(
|
||||
tokenAmount = buildSwapAmount(value = BigDecimal("0.05")),
|
||||
amountFiat = BigDecimal("100.00"),
|
||||
swapCurrencyStatus = toStatus,
|
||||
)
|
||||
|
||||
return SwapState.QuotesLoadedState(
|
||||
fromTokenInfo = fromTokenInfo,
|
||||
toTokenInfo = toTokenInfo,
|
||||
priceImpact = PriceImpact.Empty,
|
||||
preparedSwapConfigState = PreparedSwapConfigState(
|
||||
isBalanceEnough = isBalanceEnough,
|
||||
feeState = SwapFeeState.Enough,
|
||||
hasOutgoingTransaction = false,
|
||||
includeFeeInAmount = includeFeeInAmount,
|
||||
),
|
||||
permissionState = PermissionDataState.Empty,
|
||||
txFee = txFeeState,
|
||||
currencyCheck = null,
|
||||
validationResult = null,
|
||||
minAdaValue = null,
|
||||
swapProvider = buildSwapProvider(),
|
||||
)
|
||||
}
|
||||
|
||||
private fun buildSwapProvider(
|
||||
termsOfUse: String? = null,
|
||||
privacyPolicy: String? = null,
|
||||
) = SwapProvider(
|
||||
providerId = "provider-id",
|
||||
name = "TestProvider",
|
||||
type = ExchangeProviderType.DEX,
|
||||
imageLarge = "https://example.com/icon.png",
|
||||
termsOfUse = termsOfUse,
|
||||
privacyPolicy = privacyPolicy,
|
||||
isRecommended = false,
|
||||
slippage = null,
|
||||
)
|
||||
|
||||
private fun buildSwapAmount(value: BigDecimal = BigDecimal("1.0")) = SwapAmount(
|
||||
value = value,
|
||||
decimals = 18,
|
||||
)
|
||||
|
||||
private fun buildTokenSwapInfo(swapCurrencyStatus: SwapCurrencyStatus) = TokenSwapInfo(
|
||||
tokenAmount = buildSwapAmount(),
|
||||
amountFiat = BigDecimal.ZERO,
|
||||
swapCurrencyStatus = swapCurrencyStatus,
|
||||
)
|
||||
|
||||
private fun buildTxFeeLegacy(feeType: FeeType): TxFee.Legacy {
|
||||
val fee: com.tangem.blockchain.common.transaction.Fee = mockk(relaxed = true)
|
||||
return TxFee.Legacy(
|
||||
feeValue = BigDecimal("0.001"),
|
||||
feeFiatFormatted = "$2.00",
|
||||
feeCryptoFormatted = "0.001 ETH",
|
||||
feeIncludeOtherNativeFee = BigDecimal.ZERO,
|
||||
feeFiatFormattedWithNative = "$2.00",
|
||||
feeCryptoFormattedWithNative = "0.001 ETH",
|
||||
cryptoSymbol = "ETH",
|
||||
feeType = feeType,
|
||||
fee = fee,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
|
@ -1,614 +0,0 @@
|
|||
package com.tangem.feature.swap
|
||||
|
||||
import com.google.common.truth.Truth.assertThat
|
||||
import com.tangem.common.routing.AppRouter
|
||||
import com.tangem.domain.appcurrency.model.AppCurrency
|
||||
import com.tangem.domain.models.wallet.UserWallet
|
||||
import com.tangem.domain.models.wallet.UserWalletId
|
||||
import com.tangem.domain.transaction.usecase.gasless.IsGaslessFeeSupportedForNetwork
|
||||
import com.tangem.feature.swap.domain.models.domain.*
|
||||
import com.tangem.feature.swap.domain.models.ui.*
|
||||
import com.tangem.feature.swap.model.SwapProcessDataState
|
||||
import com.tangem.feature.swap.models.*
|
||||
import com.tangem.feature.swap.models.states.ProviderState
|
||||
import com.tangem.feature.swap.models.states.SwapNotificationUM
|
||||
import com.tangem.feature.swap.ui.StateBuilder
|
||||
import com.tangem.features.swap.SwapFeatureToggles
|
||||
import com.tangem.utils.Provider
|
||||
import io.mockk.every
|
||||
import io.mockk.mockk
|
||||
import kotlinx.collections.immutable.persistentListOf
|
||||
import kotlinx.collections.immutable.toImmutableList
|
||||
import org.junit.jupiter.api.BeforeEach
|
||||
import org.junit.jupiter.api.Nested
|
||||
import org.junit.jupiter.api.Test
|
||||
import org.junit.jupiter.params.ParameterizedTest
|
||||
import org.junit.jupiter.params.provider.EnumSource
|
||||
import java.math.BigDecimal
|
||||
|
||||
internal class StateBuilderSwapDataTest {
|
||||
|
||||
private val actions: UiActions = mockk(relaxed = true)
|
||||
private val isBalanceHiddenProvider: Provider<Boolean> = mockk()
|
||||
private val appCurrencyProvider: Provider<AppCurrency> = mockk()
|
||||
private val isAccountsModeProvider: Provider<Boolean> = mockk()
|
||||
private val isGaslessFeeSupportedForNetwork: IsGaslessFeeSupportedForNetwork = mockk()
|
||||
private val swapFeatureToggles: SwapFeatureToggles = mockk(relaxed = true)
|
||||
private val appRouter: AppRouter = mockk()
|
||||
|
||||
private lateinit var sut: StateBuilder
|
||||
|
||||
private val userWalletId = UserWalletId("aabbccdd")
|
||||
private val coldWallet: UserWallet.Cold = mockk(relaxed = true) {
|
||||
every { walletId } returns userWalletId
|
||||
}
|
||||
private val hotWallet: UserWallet.Hot = mockk(relaxed = true) {
|
||||
every { walletId } returns userWalletId
|
||||
}
|
||||
|
||||
private val emptyAmountState = SwapState.EmptyAmountState(
|
||||
zeroAmountEquivalent = com.tangem.core.ui.extensions.stringReference("$0.00"),
|
||||
)
|
||||
|
||||
@BeforeEach
|
||||
fun setup() {
|
||||
every { isBalanceHiddenProvider() } returns false
|
||||
every { appCurrencyProvider() } returns AppCurrency.Default
|
||||
every { isAccountsModeProvider() } returns false
|
||||
every { isGaslessFeeSupportedForNetwork(any()) } returns false
|
||||
|
||||
sut = StateBuilder(
|
||||
actions = actions,
|
||||
isBalanceHiddenProvider = isBalanceHiddenProvider,
|
||||
appCurrencyProvider = appCurrencyProvider,
|
||||
isAccountsModeProvider = isAccountsModeProvider,
|
||||
isGaslessFeeSupportedForNetwork = isGaslessFeeSupportedForNetwork,
|
||||
swapFeatureToggles = swapFeatureToggles,
|
||||
appRouter = appRouter,
|
||||
)
|
||||
}
|
||||
|
||||
// region createSwapInProgressState
|
||||
|
||||
@Nested
|
||||
inner class CreateSwapInProgressState {
|
||||
|
||||
@Test
|
||||
fun `WHEN called THEN swapButton isInProgress becomes true`() {
|
||||
val baseState = buildReadyState(coldWallet)
|
||||
|
||||
val result = sut.createSwapInProgressState(baseState)
|
||||
|
||||
assertThat(result.swapButton.isInProgress).isTrue()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `WHEN called THEN swapButton isEnabled becomes false`() {
|
||||
val baseState = buildReadyState(coldWallet)
|
||||
// force enable the button by overriding manually
|
||||
val stateWithEnabled = baseState.copy(
|
||||
swapButton = baseState.swapButton.copy(isEnabled = true),
|
||||
)
|
||||
|
||||
val result = sut.createSwapInProgressState(stateWithEnabled)
|
||||
|
||||
assertThat(result.swapButton.isEnabled).isFalse()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `WHEN called THEN all other fields remain unchanged`() {
|
||||
val baseState = buildReadyState(coldWallet)
|
||||
|
||||
val result = sut.createSwapInProgressState(baseState)
|
||||
|
||||
assertThat(result.sendCardData).isEqualTo(baseState.sendCardData)
|
||||
assertThat(result.receiveCardData).isEqualTo(baseState.receiveCardData)
|
||||
assertThat(result.fee).isEqualTo(baseState.fee)
|
||||
assertThat(result.changeCardsButtonState).isEqualTo(baseState.changeCardsButtonState)
|
||||
}
|
||||
}
|
||||
|
||||
// endregion
|
||||
|
||||
// region createSilentLoadState
|
||||
|
||||
@Nested
|
||||
inner class CreateSilentLoadState {
|
||||
|
||||
@Test
|
||||
fun `WHEN called THEN changeCardsButtonState is UPDATE_IN_PROGRESS`() {
|
||||
val baseState = buildReadyState(coldWallet)
|
||||
|
||||
val result = sut.createSilentLoadState(baseState)
|
||||
|
||||
assertThat(result.changeCardsButtonState).isEqualTo(ChangeCardsButtonState.UPDATE_IN_PROGRESS)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `GIVEN notifications without PermissionNeeded WHEN called THEN notifications remain unchanged`() {
|
||||
val errorNotification = SwapNotificationUM.Warning.SwapNotSupported
|
||||
val baseState = buildReadyState(coldWallet).copy(
|
||||
notifications = persistentListOf(errorNotification),
|
||||
)
|
||||
|
||||
val result = sut.createSilentLoadState(baseState)
|
||||
|
||||
assertThat(result.notifications).hasSize(1)
|
||||
assertThat(result.notifications[0]).isEqualTo(errorNotification)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `GIVEN notifications with PermissionNeeded WHEN called THEN PermissionNeeded is removed`() {
|
||||
val permissionNeeded = SwapNotificationUM.Info.PermissionNeeded(
|
||||
onApproveClick = {},
|
||||
onLearnMoreClick = {},
|
||||
)
|
||||
val otherNotification = SwapNotificationUM.Warning.SwapNotSupported
|
||||
val baseState = buildReadyState(coldWallet).copy(
|
||||
notifications = listOf(permissionNeeded, otherNotification).toImmutableList(),
|
||||
)
|
||||
|
||||
val result = sut.createSilentLoadState(baseState)
|
||||
|
||||
assertThat(result.notifications).hasSize(1)
|
||||
assertThat(result.notifications[0]).isEqualTo(otherNotification)
|
||||
}
|
||||
}
|
||||
|
||||
// endregion
|
||||
|
||||
// region updateSwapAmount
|
||||
|
||||
@Nested
|
||||
inner class UpdateSwapAmount {
|
||||
|
||||
@Test
|
||||
fun `GIVEN uiState has Empty sendCard WHEN called THEN returns uiState unchanged`() {
|
||||
val loadingState = sut.createInitialLoadingState()
|
||||
val fromStatus = buildSwapCurrencyStatus(coldWallet)
|
||||
|
||||
val result = sut.updateSwapAmount(
|
||||
uiState = loadingState,
|
||||
amountFormatted = "1.5",
|
||||
amountRaw = "1.5",
|
||||
fromSwapCurrencyStatus = fromStatus,
|
||||
minTxAmount = null,
|
||||
)
|
||||
|
||||
assertThat(result).isSameInstanceAs(loadingState)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `GIVEN amount is above minTxAmount WHEN called THEN inputError is Empty`() {
|
||||
val baseState = buildReadyState(coldWallet)
|
||||
val fromStatus = buildSwapCurrencyStatus(coldWallet)
|
||||
|
||||
val result = sut.updateSwapAmount(
|
||||
uiState = baseState,
|
||||
amountFormatted = "2.0",
|
||||
amountRaw = "2.0",
|
||||
fromSwapCurrencyStatus = fromStatus,
|
||||
minTxAmount = BigDecimal("1.0"),
|
||||
)
|
||||
|
||||
val sendCard = result.sendCardData as? SwapCardState.SwapCardData
|
||||
val inputtable = sendCard?.type as? TransactionCardType.Inputtable
|
||||
assertThat(inputtable?.inputError).isEqualTo(TransactionCardType.InputError.Empty)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `GIVEN amount is below minTxAmount WHEN called THEN inputError is WrongAmount`() {
|
||||
val baseState = buildReadyState(coldWallet)
|
||||
val fromStatus = buildSwapCurrencyStatus(coldWallet)
|
||||
|
||||
val result = sut.updateSwapAmount(
|
||||
uiState = baseState,
|
||||
amountFormatted = "0.5",
|
||||
amountRaw = "0.5",
|
||||
fromSwapCurrencyStatus = fromStatus,
|
||||
minTxAmount = BigDecimal("1.0"),
|
||||
)
|
||||
|
||||
val sendCard = result.sendCardData as? SwapCardState.SwapCardData
|
||||
val inputtable = sendCard?.type as? TransactionCardType.Inputtable
|
||||
assertThat(inputtable?.inputError).isEqualTo(TransactionCardType.InputError.WrongAmount)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `GIVEN minTxAmount is null WHEN called THEN inputError is Empty`() {
|
||||
val baseState = buildReadyState(coldWallet)
|
||||
val fromStatus = buildSwapCurrencyStatus(coldWallet)
|
||||
|
||||
val result = sut.updateSwapAmount(
|
||||
uiState = baseState,
|
||||
amountFormatted = "0.001",
|
||||
amountRaw = "0.001",
|
||||
fromSwapCurrencyStatus = fromStatus,
|
||||
minTxAmount = null,
|
||||
)
|
||||
|
||||
val sendCard = result.sendCardData as? SwapCardState.SwapCardData
|
||||
val inputtable = sendCard?.type as? TransactionCardType.Inputtable
|
||||
assertThat(inputtable?.inputError).isEqualTo(TransactionCardType.InputError.Empty)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `WHEN called THEN sendCardData amountTextFieldValue text is updated`() {
|
||||
val baseState = buildReadyState(coldWallet)
|
||||
val fromStatus = buildSwapCurrencyStatus(coldWallet)
|
||||
|
||||
val result = sut.updateSwapAmount(
|
||||
uiState = baseState,
|
||||
amountFormatted = "3.14",
|
||||
amountRaw = "3.14",
|
||||
fromSwapCurrencyStatus = fromStatus,
|
||||
minTxAmount = null,
|
||||
)
|
||||
|
||||
val sendCard = result.sendCardData as? SwapCardState.SwapCardData
|
||||
assertThat(sendCard?.amountTextFieldValue?.text).isEqualTo("3.14")
|
||||
}
|
||||
}
|
||||
|
||||
// endregion
|
||||
|
||||
// region updateBalanceHiddenState
|
||||
|
||||
@Nested
|
||||
inner class UpdateBalanceHiddenState {
|
||||
|
||||
@Test
|
||||
fun `GIVEN isBalanceHidden true WHEN called THEN sendCardData isBalanceHidden is true`() {
|
||||
val fromStatus = buildSwapCurrencyStatus(coldWallet)
|
||||
val toStatus = buildSwapCurrencyStatus(coldWallet)
|
||||
val baseState = sut.createInitialReadyState(
|
||||
uiStateHolder = sut.createInitialLoadingState(),
|
||||
emptyAmountState = emptyAmountState,
|
||||
fromSwapCurrencyStatus = fromStatus,
|
||||
toSwapCurrencyStatus = toStatus,
|
||||
)
|
||||
|
||||
val result = sut.updateBalanceHiddenState(baseState, isBalanceHidden = true)
|
||||
|
||||
val sendCard = result.sendCardData as? SwapCardState.SwapCardData
|
||||
assertThat(sendCard?.isBalanceHidden).isTrue()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `GIVEN isBalanceHidden true WHEN called THEN receiveCardData isBalanceHidden is true`() {
|
||||
val fromStatus = buildSwapCurrencyStatus(coldWallet)
|
||||
val toStatus = buildSwapCurrencyStatus(coldWallet)
|
||||
val baseState = sut.createInitialReadyState(
|
||||
uiStateHolder = sut.createInitialLoadingState(),
|
||||
emptyAmountState = emptyAmountState,
|
||||
fromSwapCurrencyStatus = fromStatus,
|
||||
toSwapCurrencyStatus = toStatus,
|
||||
)
|
||||
|
||||
val result = sut.updateBalanceHiddenState(baseState, isBalanceHidden = true)
|
||||
|
||||
val receiveCard = result.receiveCardData as? SwapCardState.SwapCardData
|
||||
assertThat(receiveCard?.isBalanceHidden).isTrue()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `GIVEN isBalanceHidden false WHEN called THEN both cards isBalanceHidden is false`() {
|
||||
val fromStatus = buildSwapCurrencyStatus(coldWallet)
|
||||
val toStatus = buildSwapCurrencyStatus(coldWallet)
|
||||
val baseState = sut.createInitialReadyState(
|
||||
uiStateHolder = sut.createInitialLoadingState(),
|
||||
emptyAmountState = emptyAmountState,
|
||||
fromSwapCurrencyStatus = fromStatus,
|
||||
toSwapCurrencyStatus = toStatus,
|
||||
)
|
||||
|
||||
val result = sut.updateBalanceHiddenState(baseState, isBalanceHidden = false)
|
||||
|
||||
val sendCard = result.sendCardData as? SwapCardState.SwapCardData
|
||||
val receiveCard = result.receiveCardData as? SwapCardState.SwapCardData
|
||||
assertThat(sendCard?.isBalanceHidden).isFalse()
|
||||
assertThat(receiveCard?.isBalanceHidden).isFalse()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `GIVEN sendCard is Empty type WHEN called THEN sendCard remains Empty type`() {
|
||||
val loadingState = sut.createInitialLoadingState()
|
||||
|
||||
val result = sut.updateBalanceHiddenState(loadingState, isBalanceHidden = true)
|
||||
|
||||
assertThat(result.sendCardData).isInstanceOf(SwapCardState.Empty::class.java)
|
||||
}
|
||||
}
|
||||
|
||||
// endregion
|
||||
|
||||
// region loadingPermissionState
|
||||
|
||||
@Nested
|
||||
inner class LoadingPermissionState {
|
||||
|
||||
@Test
|
||||
fun `WHEN called THEN swapButton isEnabled is false`() {
|
||||
val baseState = buildReadyState(coldWallet).copy(
|
||||
swapButton = buildReadyState(coldWallet).swapButton.copy(isEnabled = true),
|
||||
)
|
||||
|
||||
val result = sut.loadingPermissionState(baseState)
|
||||
|
||||
assertThat(result.swapButton.isEnabled).isFalse()
|
||||
}
|
||||
|
||||
@ParameterizedTest
|
||||
@EnumSource(
|
||||
value = SwapButton.Mode::class,
|
||||
mode = EnumSource.Mode.INCLUDE,
|
||||
names = ["SWAP_PROGRESSING", "TRANSFER_PROGRESSING"],
|
||||
)
|
||||
fun `WHEN called THEN swapButton isInProgress is false`(mode: SwapButton.Mode) {
|
||||
val baseState = buildReadyState(coldWallet).copy(
|
||||
swapButton = buildReadyState(coldWallet).swapButton.copy(
|
||||
mode = mode,
|
||||
),
|
||||
)
|
||||
|
||||
val result = sut.loadingPermissionState(baseState)
|
||||
|
||||
assertThat(result.swapButton.isInProgress).isFalse()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `GIVEN notifications without PermissionNeeded WHEN called THEN ApprovalInProgressWarning is prepended`() {
|
||||
val existingNotification = SwapNotificationUM.Warning.SwapNotSupported
|
||||
val baseState = buildReadyState(coldWallet).copy(
|
||||
notifications = persistentListOf(existingNotification),
|
||||
)
|
||||
|
||||
val result = sut.loadingPermissionState(baseState)
|
||||
|
||||
assertThat(result.notifications[0]).isInstanceOf(SwapNotificationUM.Error.ApprovalInProgressWarning::class.java)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `GIVEN notifications with PermissionNeeded WHEN called THEN PermissionNeeded is replaced by ApprovalInProgressWarning`() {
|
||||
val permissionNeeded = SwapNotificationUM.Info.PermissionNeeded(
|
||||
onApproveClick = {},
|
||||
onLearnMoreClick = {},
|
||||
)
|
||||
val baseState = buildReadyState(coldWallet).copy(
|
||||
notifications = persistentListOf(permissionNeeded),
|
||||
)
|
||||
|
||||
val result = sut.loadingPermissionState(baseState)
|
||||
|
||||
assertThat(result.notifications).doesNotContain(permissionNeeded)
|
||||
assertThat(result.notifications[0]).isInstanceOf(SwapNotificationUM.Error.ApprovalInProgressWarning::class.java)
|
||||
}
|
||||
}
|
||||
|
||||
// endregion
|
||||
|
||||
// region dismissBottomSheet
|
||||
|
||||
@Nested
|
||||
inner class DismissBottomSheet {
|
||||
|
||||
@Test
|
||||
fun `GIVEN bottomSheetConfig is null WHEN called THEN bottomSheetConfig remains null`() {
|
||||
val baseState = buildReadyState(coldWallet)
|
||||
assertThat(baseState.bottomSheetConfig).isNull()
|
||||
|
||||
val result = sut.dismissBottomSheet(baseState)
|
||||
|
||||
assertThat(result.bottomSheetConfig).isNull()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `GIVEN bottomSheetConfig is shown WHEN called THEN bottomSheetConfig isShown becomes false`() {
|
||||
val baseState = buildReadyState(coldWallet).copy(
|
||||
bottomSheetConfig = com.tangem.core.ui.components.bottomsheets.TangemBottomSheetConfig(
|
||||
isShown = true,
|
||||
onDismissRequest = {},
|
||||
content = mockk(relaxed = true),
|
||||
),
|
||||
)
|
||||
|
||||
val result = sut.dismissBottomSheet(baseState)
|
||||
|
||||
assertThat(result.bottomSheetConfig?.isShown).isFalse()
|
||||
}
|
||||
}
|
||||
|
||||
// endregion
|
||||
|
||||
// region addNotification
|
||||
|
||||
@Nested
|
||||
inner class AddNotification {
|
||||
|
||||
@Test
|
||||
fun `GIVEN a message WHEN called THEN notifications contains GenericError`() {
|
||||
val baseState = buildReadyState(coldWallet)
|
||||
val message = com.tangem.core.ui.extensions.stringReference("Something went wrong")
|
||||
|
||||
val result = sut.addNotification(
|
||||
uiState = baseState,
|
||||
message = message,
|
||||
onClick = {},
|
||||
)
|
||||
|
||||
assertThat(result.notifications).hasSize(1)
|
||||
assertThat(result.notifications[0]).isInstanceOf(SwapNotificationUM.Error.GenericError::class.java)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `GIVEN null message WHEN called THEN notifications contains GenericError`() {
|
||||
val baseState = buildReadyState(coldWallet)
|
||||
|
||||
val result = sut.addNotification(
|
||||
uiState = baseState,
|
||||
message = null,
|
||||
onClick = {},
|
||||
)
|
||||
|
||||
assertThat(result.notifications).hasSize(1)
|
||||
assertThat(result.notifications[0]).isInstanceOf(SwapNotificationUM.Error.GenericError::class.java)
|
||||
}
|
||||
}
|
||||
|
||||
// endregion
|
||||
|
||||
// region createSuccessState
|
||||
|
||||
@Nested
|
||||
inner class CreateSuccessState {
|
||||
|
||||
@Test
|
||||
fun `GIVEN valid state WHEN called THEN successState is not null`() {
|
||||
val baseState = buildReadyStateWithContentProvider(coldWallet)
|
||||
val fromStatus = buildSwapCurrencyStatus(coldWallet)
|
||||
val toStatus = buildSwapCurrencyStatus(coldWallet)
|
||||
val dataState = SwapProcessDataState(
|
||||
fromSwapCurrencyStatus = fromStatus,
|
||||
toSwapCurrencyStatus = toStatus,
|
||||
selectedFee = null,
|
||||
)
|
||||
val swapTransactionState = buildSwapTransactionState()
|
||||
|
||||
val result = sut.createSuccessState(
|
||||
uiState = baseState,
|
||||
swapTransactionState = swapTransactionState,
|
||||
dataState = dataState,
|
||||
onExploreClick = {},
|
||||
onStatusClick = {},
|
||||
txUrl = "https://example.com/tx/abc",
|
||||
)
|
||||
|
||||
assertThat(result.successState).isNotNull()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `GIVEN CEX provider WHEN called THEN shouldShowStatusButton is true`() {
|
||||
val baseState = buildReadyStateWithContentProvider(
|
||||
coldWallet,
|
||||
providerType = ExchangeProviderType.CEX,
|
||||
)
|
||||
val fromStatus = buildSwapCurrencyStatus(coldWallet)
|
||||
val toStatus = buildSwapCurrencyStatus(coldWallet)
|
||||
val dataState = SwapProcessDataState(
|
||||
fromSwapCurrencyStatus = fromStatus,
|
||||
toSwapCurrencyStatus = toStatus,
|
||||
selectedFee = null,
|
||||
)
|
||||
val swapTransactionState = buildSwapTransactionState()
|
||||
|
||||
val result = sut.createSuccessState(
|
||||
uiState = baseState,
|
||||
swapTransactionState = swapTransactionState,
|
||||
dataState = dataState,
|
||||
onExploreClick = {},
|
||||
onStatusClick = {},
|
||||
txUrl = "https://example.com/tx/abc",
|
||||
)
|
||||
|
||||
assertThat(result.successState?.shouldShowStatusButton).isTrue()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `GIVEN DEX provider WHEN called THEN shouldShowStatusButton is false`() {
|
||||
val baseState = buildReadyStateWithContentProvider(
|
||||
coldWallet,
|
||||
providerType = ExchangeProviderType.DEX,
|
||||
)
|
||||
val fromStatus = buildSwapCurrencyStatus(coldWallet)
|
||||
val toStatus = buildSwapCurrencyStatus(coldWallet)
|
||||
val dataState = SwapProcessDataState(
|
||||
fromSwapCurrencyStatus = fromStatus,
|
||||
toSwapCurrencyStatus = toStatus,
|
||||
selectedFee = null,
|
||||
)
|
||||
val swapTransactionState = buildSwapTransactionState()
|
||||
|
||||
val result = sut.createSuccessState(
|
||||
uiState = baseState,
|
||||
swapTransactionState = swapTransactionState,
|
||||
dataState = dataState,
|
||||
onExploreClick = {},
|
||||
onStatusClick = {},
|
||||
txUrl = "https://example.com/tx/abc",
|
||||
)
|
||||
|
||||
assertThat(result.successState?.shouldShowStatusButton).isFalse()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `GIVEN txUrl WHEN called THEN successState txUrl matches`() {
|
||||
val baseState = buildReadyStateWithContentProvider(coldWallet)
|
||||
val fromStatus = buildSwapCurrencyStatus(coldWallet)
|
||||
val toStatus = buildSwapCurrencyStatus(coldWallet)
|
||||
val dataState = SwapProcessDataState(
|
||||
fromSwapCurrencyStatus = fromStatus,
|
||||
toSwapCurrencyStatus = toStatus,
|
||||
selectedFee = null,
|
||||
)
|
||||
val swapTransactionState = buildSwapTransactionState()
|
||||
val expectedUrl = "https://etherscan.io/tx/0xabc"
|
||||
|
||||
val result = sut.createSuccessState(
|
||||
uiState = baseState,
|
||||
swapTransactionState = swapTransactionState,
|
||||
dataState = dataState,
|
||||
onExploreClick = {},
|
||||
onStatusClick = {},
|
||||
txUrl = expectedUrl,
|
||||
)
|
||||
|
||||
assertThat(result.successState?.txUrl).isEqualTo(expectedUrl)
|
||||
}
|
||||
}
|
||||
|
||||
// endregion
|
||||
|
||||
// --- Helpers ---
|
||||
|
||||
private fun buildReadyState(userWallet: UserWallet): SwapStateHolder {
|
||||
val fromStatus = buildSwapCurrencyStatus(userWallet)
|
||||
val toStatus = buildSwapCurrencyStatus(userWallet)
|
||||
return sut.createInitialReadyState(
|
||||
uiStateHolder = sut.createInitialLoadingState(),
|
||||
emptyAmountState = emptyAmountState,
|
||||
fromSwapCurrencyStatus = fromStatus,
|
||||
toSwapCurrencyStatus = toStatus,
|
||||
)
|
||||
}
|
||||
|
||||
private fun buildReadyStateWithContentProvider(
|
||||
userWallet: UserWallet,
|
||||
providerType: ExchangeProviderType = ExchangeProviderType.DEX,
|
||||
): SwapStateHolder {
|
||||
val baseState = buildReadyState(userWallet)
|
||||
return baseState.copy(
|
||||
providerState = ProviderState.Content(
|
||||
id = "provider-id",
|
||||
name = "TestProvider",
|
||||
type = providerType.providerName,
|
||||
iconUrl = "https://example.com/icon.png",
|
||||
subtitle = com.tangem.core.ui.extensions.stringReference("1 ETH ≈ 2000 USDT"),
|
||||
additionalBadge = ProviderState.AdditionalBadge.Empty,
|
||||
selectionType = ProviderState.SelectionType.CLICK,
|
||||
namePrefix = ProviderState.PrefixType.NONE,
|
||||
onProviderClick = {},
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
private fun buildSwapTransactionState(): SwapTransactionState.TxSent {
|
||||
return SwapTransactionState.TxSent(
|
||||
fromAmount = "1.0 ETH",
|
||||
toAmount = "2000 USDT",
|
||||
fromAmountValue = BigDecimal("1.0"),
|
||||
toAmountValue = BigDecimal("2000"),
|
||||
txHash = "0xabc",
|
||||
timestamp = System.currentTimeMillis(),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
|
@ -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,
|
||||
),
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue