Initial commit: Pezkuwi Wallet Android

Complete rebrand of Nova Wallet for Pezkuwichain ecosystem.

## Features
- Full Pezkuwichain support (HEZ & PEZ tokens)
- Polkadot ecosystem compatibility
- Staking, Governance, DeFi, NFTs
- XCM cross-chain transfers
- Hardware wallet support (Ledger, Polkadot Vault)
- WalletConnect v2
- Push notifications

## Languages
- English, Turkish, Kurmanci (Kurdish), Spanish, French, German, Russian, Japanese, Chinese, Korean, Portuguese, Vietnamese

Based on Nova Wallet by Novasama Technologies GmbH
© Dijital Kurdistan Tech Institute 2026
This commit is contained in:
2026-01-23 01:31:12 +03:00
commit 31c8c5995f
7621 changed files with 425838 additions and 0 deletions
@@ -0,0 +1,4 @@
<?xml version="1.0" encoding="utf-8"?>
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
</manifest>
@@ -0,0 +1,57 @@
package io.novafoundation.nova.feature_swap_impl.data.assetExchange
import io.novafoundation.nova.feature_account_api.data.extrinsic.ExtrinsicService
import io.novafoundation.nova.feature_account_api.data.fee.FeePaymentProvider
import io.novafoundation.nova.feature_account_api.domain.model.MetaAccount
import io.novafoundation.nova.feature_swap_api.domain.model.ReQuoteTrigger
import io.novafoundation.nova.feature_swap_api.domain.model.SwapGraphEdge
import io.novafoundation.nova.feature_swap_core_api.data.primitive.SwapQuoting.QuotingHost
import io.novafoundation.nova.feature_swap_core_api.data.primitive.model.SwapDirection
import io.novafoundation.nova.feature_wallet_api.data.network.blockhain.types.Balance
import io.novafoundation.nova.runtime.multiNetwork.chain.model.Chain
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.flow.Flow
interface AssetExchange {
interface SingleChainFactory {
suspend fun create(chain: Chain, swapHost: SwapHost): AssetExchange
}
interface MultiChainFactory {
suspend fun create(swapHost: SwapHost): AssetExchange
}
interface SwapHost : QuotingHost {
val scope: CoroutineScope
override val sharedSubscriptions: SharedSwapSubscriptions
suspend fun quote(quoteArgs: ParentQuoterArgs): Balance
suspend fun extrinsicService(): ExtrinsicService
}
suspend fun sync()
suspend fun availableDirectSwapConnections(): List<SwapGraphEdge>
fun feePaymentOverrides(): List<FeePaymentProviderOverride>
fun runSubscriptions(metaAccount: MetaAccount): Flow<ReQuoteTrigger>
}
data class FeePaymentProviderOverride(
val provider: FeePaymentProvider,
val chain: Chain
)
data class ParentQuoterArgs(
val chainAssetIn: Chain.Asset,
val chainAssetOut: Chain.Asset,
val amount: Balance,
val swapDirection: SwapDirection,
)
@@ -0,0 +1,5 @@
package io.novafoundation.nova.feature_swap_impl.data.assetExchange
import io.novafoundation.nova.feature_swap_core_api.data.primitive.SwapQuotingSubscriptions
interface SharedSwapSubscriptions : SwapQuotingSubscriptions
@@ -0,0 +1,374 @@
package io.novafoundation.nova.feature_swap_impl.data.assetExchange.assetConversion
import io.novafoundation.nova.common.data.network.runtime.binding.bindNumber
import io.novafoundation.nova.common.data.network.runtime.binding.bindNumberOrNull
import io.novafoundation.nova.common.utils.Modules
import io.novafoundation.nova.common.utils.assetConversionAssetIdType
import io.novafoundation.nova.common.utils.graph.Path
import io.novafoundation.nova.common.utils.graph.WeightedEdge
import io.novafoundation.nova.common.utils.metadata
import io.novafoundation.nova.feature_account_api.data.conversion.assethub.assetConversionOrNull
import io.novafoundation.nova.feature_account_api.data.conversion.assethub.pools
import io.novafoundation.nova.feature_account_api.data.ethereum.transaction.TransactionOrigin
import io.novafoundation.nova.feature_account_api.data.extrinsic.ExtrinsicService
import io.novafoundation.nova.feature_account_api.data.extrinsic.execution.ExtrinsicExecutionResult
import io.novafoundation.nova.feature_account_api.data.extrinsic.execution.requireOk
import io.novafoundation.nova.feature_account_api.data.extrinsic.execution.requireOutcomeOk
import io.novafoundation.nova.feature_account_api.domain.model.MetaAccount
import io.novafoundation.nova.feature_swap_api.domain.model.AtomicOperationDisplayData
import io.novafoundation.nova.feature_swap_api.domain.model.AtomicSwapOperation
import io.novafoundation.nova.feature_swap_api.domain.model.AtomicSwapOperationArgs
import io.novafoundation.nova.feature_swap_api.domain.model.AtomicSwapOperationPrototype
import io.novafoundation.nova.feature_swap_api.domain.model.AtomicSwapOperationSubmissionArgs
import io.novafoundation.nova.feature_swap_api.domain.model.ReQuoteTrigger
import io.novafoundation.nova.feature_swap_api.domain.model.SwapExecutionCorrection
import io.novafoundation.nova.feature_swap_api.domain.model.SwapGraphEdge
import io.novafoundation.nova.feature_swap_api.domain.model.SwapLimit
import io.novafoundation.nova.feature_swap_api.domain.model.SwapMaxAdditionalAmountDeduction
import io.novafoundation.nova.feature_swap_api.domain.model.SwapSubmissionResult
import io.novafoundation.nova.feature_swap_api.domain.model.UsdConverter
import io.novafoundation.nova.feature_swap_api.domain.model.estimatedAmountIn
import io.novafoundation.nova.feature_swap_api.domain.model.estimatedAmountOut
import io.novafoundation.nova.feature_swap_api.domain.model.fee.AtomicSwapOperationFee
import io.novafoundation.nova.feature_swap_api.domain.model.fee.SubmissionOnlyAtomicSwapOperationFee
import io.novafoundation.nova.feature_swap_core.data.assetExchange.conversion.types.hydra.sources.Weights
import io.novafoundation.nova.feature_swap_core_api.data.primitive.errors.SwapQuoteException
import io.novafoundation.nova.feature_swap_core_api.data.primitive.model.SwapDirection
import io.novafoundation.nova.feature_swap_impl.data.assetExchange.AssetExchange
import io.novafoundation.nova.feature_swap_impl.data.assetExchange.FeePaymentProviderOverride
import io.novafoundation.nova.feature_swap_impl.data.assetExchange.ParentQuoterArgs
import io.novafoundation.nova.feature_swap_impl.domain.AssetInAdditionalSwapDeductionUseCase
import io.novafoundation.nova.feature_swap_impl.domain.swap.BaseSwapGraphEdge
import io.novafoundation.nova.feature_wallet_api.data.network.blockhain.types.Balance
import io.novafoundation.nova.feature_wallet_api.domain.model.withAmount
import io.novafoundation.nova.feature_xcm_api.converter.MultiLocationConverter
import io.novafoundation.nova.feature_xcm_api.converter.MultiLocationConverterFactory
import io.novafoundation.nova.feature_xcm_api.converter.toMultiLocationOrThrow
import io.novafoundation.nova.feature_xcm_api.multiLocation.RelativeMultiLocation
import io.novafoundation.nova.feature_xcm_api.versions.XcmVersion
import io.novafoundation.nova.feature_xcm_api.versions.detector.XcmVersionDetector
import io.novafoundation.nova.feature_xcm_api.versions.orDefault
import io.novafoundation.nova.runtime.call.MultiChainRuntimeCallsApi
import io.novafoundation.nova.runtime.call.RuntimeCallsApi
import io.novafoundation.nova.runtime.ext.emptyAccountId
import io.novafoundation.nova.runtime.ext.fullId
import io.novafoundation.nova.runtime.multiNetwork.chain.model.Chain
import io.novafoundation.nova.runtime.multiNetwork.chain.model.ChainId
import io.novafoundation.nova.runtime.multiNetwork.chain.model.FullChainAssetId
import io.novafoundation.nova.runtime.multiNetwork.runtime.repository.findEventOrThrow
import io.novafoundation.nova.runtime.repository.ChainStateRepository
import io.novafoundation.nova.runtime.repository.expectedBlockTime
import io.novafoundation.nova.runtime.storage.source.StorageDataSource
import io.novasama.substrate_sdk_android.runtime.AccountId
import io.novasama.substrate_sdk_android.runtime.RuntimeSnapshot
import io.novasama.substrate_sdk_android.runtime.definitions.types.generics.GenericEvent
import io.novasama.substrate_sdk_android.runtime.extrinsic.builder.ExtrinsicBuilder
import io.novasama.substrate_sdk_android.runtime.extrinsic.call
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.drop
import kotlinx.coroutines.flow.map
import java.math.BigDecimal
import kotlin.time.Duration
class AssetConversionExchangeFactory(
private val multiLocationConverterFactory: MultiLocationConverterFactory,
private val remoteStorageSource: StorageDataSource,
private val runtimeCallsApi: MultiChainRuntimeCallsApi,
private val chainStateRepository: ChainStateRepository,
private val deductionUseCase: AssetInAdditionalSwapDeductionUseCase,
private val xcmVersionDetector: XcmVersionDetector,
) : AssetExchange.SingleChainFactory {
override suspend fun create(
chain: Chain,
swapHost: AssetExchange.SwapHost,
): AssetExchange {
val converter = multiLocationConverterFactory.defaultAsync(chain, swapHost.scope)
return AssetConversionExchange(
chain = chain,
multiLocationConverter = converter,
remoteStorageSource = remoteStorageSource,
multiChainRuntimeCallsApi = runtimeCallsApi,
chainStateRepository = chainStateRepository,
swapHost = swapHost,
deductionUseCase = deductionUseCase,
xcmVersionDetector = xcmVersionDetector
)
}
}
private class AssetConversionExchange(
private val chain: Chain,
private val multiLocationConverter: MultiLocationConverter,
private val remoteStorageSource: StorageDataSource,
private val multiChainRuntimeCallsApi: MultiChainRuntimeCallsApi,
private val chainStateRepository: ChainStateRepository,
private val swapHost: AssetExchange.SwapHost,
private val deductionUseCase: AssetInAdditionalSwapDeductionUseCase,
private val xcmVersionDetector: XcmVersionDetector,
) : AssetExchange {
override suspend fun sync() {
// nothing to sync
}
override suspend fun availableDirectSwapConnections(): List<SwapGraphEdge> {
return remoteStorageSource.query(chain.id) {
val allPools = metadata.assetConversionOrNull?.pools?.keys().orEmpty()
constructAllAvailableDirections(allPools)
}
}
override fun feePaymentOverrides(): List<FeePaymentProviderOverride> {
return emptyList()
}
override fun runSubscriptions(metaAccount: MetaAccount): Flow<ReQuoteTrigger> {
return chainStateRepository.currentBlockNumberFlow(chain.id)
.drop(1) // skip immediate value from the cache to not perform double-quote on chain change
.map { ReQuoteTrigger }
}
private suspend fun constructAllAvailableDirections(pools: List<Pair<RelativeMultiLocation, RelativeMultiLocation>>): List<AssetConversionEdge> {
return buildList {
pools.forEach { (firstLocation, secondLocation) ->
val firstAsset = multiLocationConverter.toChainAsset(firstLocation) ?: return@forEach
val secondAsset = multiLocationConverter.toChainAsset(secondLocation) ?: return@forEach
add(AssetConversionEdge(firstAsset, secondAsset))
add(AssetConversionEdge(secondAsset, firstAsset))
}
}
}
private suspend fun detectAssetIdXcmVersion(runtime: RuntimeSnapshot): XcmVersion {
val assetIdType = runtime.metadata.assetConversionAssetIdType()
return xcmVersionDetector.detectMultiLocationVersion(chain.id, assetIdType).orDefault()
}
private suspend fun RuntimeCallsApi.quote(
swapDirection: SwapDirection,
assetIn: Chain.Asset,
assetOut: Chain.Asset,
amount: Balance,
): Balance? {
val method = when (swapDirection) {
SwapDirection.SPECIFIED_IN -> "quote_price_exact_tokens_for_tokens"
SwapDirection.SPECIFIED_OUT -> "quote_price_tokens_for_exact_tokens"
}
val assetIdXcmVersion = detectAssetIdXcmVersion(runtime)
val asset1 = multiLocationConverter.toMultiLocationOrThrow(assetIn).toEncodableInstance(assetIdXcmVersion)
val asset2 = multiLocationConverter.toMultiLocationOrThrow(assetOut).toEncodableInstance(assetIdXcmVersion)
return call(
section = "AssetConversionApi",
method = method,
arguments = mapOf(
"asset1" to asset1,
"asset2" to asset2,
"amount" to amount,
"include_fee" to true
),
returnBinding = ::bindNumberOrNull
)
}
private inner class AssetConversionEdge(fromAsset: Chain.Asset, toAsset: Chain.Asset) : BaseSwapGraphEdge(fromAsset, toAsset) {
override suspend fun beginOperation(args: AtomicSwapOperationArgs): AtomicSwapOperation {
return AssetConversionOperation(args, fromAsset, toAsset)
}
override suspend fun appendToOperation(currentTransaction: AtomicSwapOperation, args: AtomicSwapOperationArgs): AtomicSwapOperation? {
return null
}
override suspend fun beginOperationPrototype(): AtomicSwapOperationPrototype {
return AssetConversionOperationPrototype(fromAsset.chainId)
}
override suspend fun appendToOperationPrototype(currentTransaction: AtomicSwapOperationPrototype): AtomicSwapOperationPrototype? {
return null
}
override suspend fun debugLabel(): String {
return "AssetConversion"
}
override fun predecessorHandlesFees(predecessor: SwapGraphEdge): Boolean {
return false
}
override suspend fun canPayNonNativeFeesInIntermediatePosition(): Boolean {
return true
}
override suspend fun canTransferOutWholeAccountBalance(): Boolean {
return true
}
override suspend fun quote(
amount: Balance,
direction: SwapDirection
): Balance {
val runtimeCallsApi = multiChainRuntimeCallsApi.forChain(chain.id)
return runtimeCallsApi.quote(
swapDirection = direction,
assetIn = fromAsset,
assetOut = toAsset,
amount = amount
) ?: throw SwapQuoteException.NotEnoughLiquidity
}
override fun weightForAppendingTo(path: Path<WeightedEdge<FullChainAssetId>>): Int {
return Weights.AssetConversion.SWAP
}
}
inner class AssetConversionOperationPrototype(override val fromChain: ChainId) : AtomicSwapOperationPrototype {
override suspend fun roughlyEstimateNativeFee(usdConverter: UsdConverter): BigDecimal {
// in DOT
return 0.0015.toBigDecimal()
}
override suspend fun maximumExecutionTime(): Duration {
return chainStateRepository.expectedBlockTime(chain.id)
}
}
inner class AssetConversionOperation(
private val transactionArgs: AtomicSwapOperationArgs,
private val fromAsset: Chain.Asset,
private val toAsset: Chain.Asset
) : AtomicSwapOperation {
override val estimatedSwapLimit: SwapLimit = transactionArgs.estimatedSwapLimit
override val assetOut: FullChainAssetId = toAsset.fullId
override val assetIn: FullChainAssetId = fromAsset.fullId
override suspend fun constructDisplayData(): AtomicOperationDisplayData {
return AtomicOperationDisplayData.Swap(
from = fromAsset.fullId.withAmount(estimatedSwapLimit.estimatedAmountIn),
to = toAsset.fullId.withAmount(estimatedSwapLimit.estimatedAmountOut),
)
}
override suspend fun estimateFee(): AtomicSwapOperationFee {
val submissionFee = swapHost.extrinsicService().estimateFee(
chain = chain,
origin = TransactionOrigin.SelectedWallet,
submissionOptions = ExtrinsicService.SubmissionOptions(
feePaymentCurrency = transactionArgs.feePaymentCurrency
)
) {
executeSwap(swapLimit = estimatedSwapLimit, sendTo = chain.emptyAccountId())
}
return SubmissionOnlyAtomicSwapOperationFee(submissionFee)
}
override suspend fun requiredAmountInToGetAmountOut(extraOutAmount: Balance): Balance {
val quoteArgs = ParentQuoterArgs(
chainAssetIn = fromAsset,
chainAssetOut = toAsset,
amount = extraOutAmount,
swapDirection = SwapDirection.SPECIFIED_OUT
)
return swapHost.quote(quoteArgs)
}
override suspend fun additionalMaxAmountDeduction(): SwapMaxAdditionalAmountDeduction {
return SwapMaxAdditionalAmountDeduction(
fromCountedTowardsEd = deductionUseCase.invoke(fromAsset, toAsset)
)
}
override suspend fun execute(args: AtomicSwapOperationSubmissionArgs): Result<SwapExecutionCorrection> {
return submitInternal(args)
.mapCatching {
SwapExecutionCorrection(
actualReceivedAmount = it.requireOutcomeOk().emittedEvents.determineActualSwappedAmount()
)
}
}
override suspend fun submit(args: AtomicSwapOperationSubmissionArgs): Result<SwapSubmissionResult> {
return submitInternal(args)
.map { SwapSubmissionResult(it.submissionHierarchy) }
}
private suspend fun submitInternal(args: AtomicSwapOperationSubmissionArgs): Result<ExtrinsicExecutionResult> {
return swapHost.extrinsicService().submitExtrinsicAndAwaitExecution(
chain = chain,
origin = TransactionOrigin.SelectedWallet,
submissionOptions = ExtrinsicService.SubmissionOptions(
feePaymentCurrency = transactionArgs.feePaymentCurrency
)
) { buildingContext ->
// Send swapped funds to the executingAccount since it the account doing the swap
executeSwap(swapLimit = args.actualSwapLimit, sendTo = buildingContext.submissionOrigin.executingAccount)
}.requireOk()
}
private fun List<GenericEvent.Instance>.determineActualSwappedAmount(): Balance {
val swap = findEventOrThrow(Modules.ASSET_CONVERSION, "SwapExecuted")
val (_, _, _, amountOut) = swap.arguments
return bindNumber(amountOut)
}
private suspend fun ExtrinsicBuilder.executeSwap(
swapLimit: SwapLimit,
sendTo: AccountId
) {
val assetIdXcmVersion = detectAssetIdXcmVersion(runtime)
val path = listOf(fromAsset, toAsset)
.map { asset -> multiLocationConverter.encodableMultiLocationOf(asset, assetIdXcmVersion) }
val keepAlive = false
when (swapLimit) {
is SwapLimit.SpecifiedIn -> call(
moduleName = Modules.ASSET_CONVERSION,
callName = "swap_exact_tokens_for_tokens",
arguments = mapOf(
"path" to path,
"amount_in" to swapLimit.amountIn,
"amount_out_min" to swapLimit.amountOutMin,
"send_to" to sendTo,
"keep_alive" to keepAlive
)
)
is SwapLimit.SpecifiedOut -> call(
moduleName = Modules.ASSET_CONVERSION,
callName = "swap_tokens_for_exact_tokens",
arguments = mapOf(
"path" to path,
"amount_out" to swapLimit.amountOut,
"amount_in_max" to swapLimit.amountInMax,
"send_to" to sendTo,
"keep_alive" to keepAlive
)
)
}
}
private suspend fun MultiLocationConverter.encodableMultiLocationOf(
chainAsset: Chain.Asset,
xcmVersion: XcmVersion
): Any {
return toMultiLocationOrThrow(chainAsset).toEncodableInstance(xcmVersion)
}
}
}
@@ -0,0 +1,358 @@
package io.novafoundation.nova.feature_swap_impl.data.assetExchange.crossChain
import io.novafoundation.nova.common.utils.firstNotNull
import io.novafoundation.nova.common.utils.flatMap
import io.novafoundation.nova.common.utils.graph.Edge
import io.novafoundation.nova.common.utils.graph.Path
import io.novafoundation.nova.common.utils.graph.WeightedEdge
import io.novafoundation.nova.common.utils.mapError
import io.novafoundation.nova.common.utils.mapToSet
import io.novafoundation.nova.common.utils.orZero
import io.novafoundation.nova.feature_account_api.data.model.addPlanks
import io.novafoundation.nova.feature_account_api.domain.interfaces.AccountRepository
import io.novafoundation.nova.feature_account_api.domain.model.MetaAccount
import io.novafoundation.nova.feature_account_api.domain.model.requireAccountIdKeyIn
import io.novafoundation.nova.feature_account_api.domain.model.requireAddressIn
import io.novafoundation.nova.feature_swap_api.domain.model.AtomicOperationDisplayData
import io.novafoundation.nova.feature_swap_api.domain.model.AtomicOperationFeeDisplayData
import io.novafoundation.nova.feature_swap_api.domain.model.AtomicOperationFeeDisplayData.SwapFeeComponentDisplay
import io.novafoundation.nova.feature_swap_api.domain.model.AtomicSwapOperation
import io.novafoundation.nova.feature_swap_api.domain.model.AtomicSwapOperationArgs
import io.novafoundation.nova.feature_swap_api.domain.model.AtomicSwapOperationPrototype
import io.novafoundation.nova.feature_swap_api.domain.model.AtomicSwapOperationSubmissionArgs
import io.novafoundation.nova.feature_swap_api.domain.model.FeeWithLabel
import io.novafoundation.nova.feature_swap_api.domain.model.ReQuoteTrigger
import io.novafoundation.nova.feature_swap_api.domain.model.SubmissionFeeWithLabel
import io.novafoundation.nova.feature_swap_api.domain.model.SwapExecutionCorrection
import io.novafoundation.nova.feature_swap_api.domain.model.SwapGraphEdge
import io.novafoundation.nova.feature_swap_api.domain.model.SwapLimit
import io.novafoundation.nova.feature_swap_api.domain.model.SwapMaxAdditionalAmountDeduction
import io.novafoundation.nova.feature_swap_api.domain.model.SwapOperationSubmissionException
import io.novafoundation.nova.feature_swap_api.domain.model.SwapSubmissionResult
import io.novafoundation.nova.feature_swap_api.domain.model.UsdConverter
import io.novafoundation.nova.feature_swap_api.domain.model.crossChain
import io.novafoundation.nova.feature_swap_api.domain.model.estimatedAmountIn
import io.novafoundation.nova.feature_swap_api.domain.model.fee.AtomicSwapOperationFee
import io.novafoundation.nova.feature_swap_api.domain.model.network
import io.novafoundation.nova.feature_swap_core.data.assetExchange.conversion.types.hydra.sources.Weights
import io.novafoundation.nova.feature_swap_core_api.data.primitive.model.SwapDirection
import io.novafoundation.nova.feature_swap_impl.data.assetExchange.AssetExchange
import io.novafoundation.nova.feature_swap_impl.data.assetExchange.FeePaymentProviderOverride
import io.novafoundation.nova.feature_wallet_api.data.network.blockhain.assets.tranfers.AssetTransferBase
import io.novafoundation.nova.feature_wallet_api.data.network.blockhain.assets.tranfers.AssetTransferDirection
import io.novafoundation.nova.feature_wallet_api.data.network.blockhain.types.Balance
import io.novafoundation.nova.feature_wallet_api.data.network.crosschain.XcmTransferDryRunOrigin
import io.novafoundation.nova.feature_wallet_api.domain.interfaces.CrossChainTransfersUseCase
import io.novafoundation.nova.feature_wallet_api.domain.model.CrossChainTransferFee
import io.novafoundation.nova.feature_wallet_api.domain.model.xcm.CrossChainTransfersConfiguration
import io.novafoundation.nova.feature_wallet_api.domain.model.xcm.availableInDestinations
import io.novafoundation.nova.feature_wallet_api.domain.model.xcm.dynamic.transferFeatures
import io.novafoundation.nova.feature_wallet_api.domain.model.xcm.hasDeliveryFee
import io.novafoundation.nova.runtime.ext.Geneses
import io.novafoundation.nova.runtime.ext.fullId
import io.novafoundation.nova.runtime.multiNetwork.ChainRegistry
import io.novafoundation.nova.runtime.multiNetwork.chain.model.Chain
import io.novafoundation.nova.runtime.multiNetwork.chain.model.ChainId
import io.novafoundation.nova.runtime.multiNetwork.chain.model.FullChainAssetId
import io.novafoundation.nova.runtime.multiNetwork.chainWithAsset
import io.novafoundation.nova.runtime.multiNetwork.disabledChains
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.emptyFlow
import java.math.BigDecimal
import java.math.BigInteger
import kotlin.time.Duration
class CrossChainTransferAssetExchangeFactory(
private val crossChainTransfersUseCase: CrossChainTransfersUseCase,
private val chainRegistry: ChainRegistry,
private val accountRepository: AccountRepository,
) : AssetExchange.MultiChainFactory {
override suspend fun create(
swapHost: AssetExchange.SwapHost
): AssetExchange {
return CrossChainTransferAssetExchange(
crossChainTransfersUseCase = crossChainTransfersUseCase,
chainRegistry = chainRegistry,
accountRepository = accountRepository,
swapHost = swapHost
)
}
}
class CrossChainTransferAssetExchange(
private val crossChainTransfersUseCase: CrossChainTransfersUseCase,
private val chainRegistry: ChainRegistry,
private val accountRepository: AccountRepository,
private val swapHost: AssetExchange.SwapHost,
) : AssetExchange {
private val crossChainConfig = MutableStateFlow<CrossChainTransfersConfiguration?>(null)
override suspend fun sync() {
crossChainTransfersUseCase.syncCrossChainConfig()
crossChainConfig.emit(crossChainTransfersUseCase.getConfiguration())
}
override suspend fun availableDirectSwapConnections(): List<SwapGraphEdge> {
val config = crossChainConfig.firstNotNull()
val disabledChainIds = chainRegistry.disabledChains().mapToSet { it.id }
return config.availableInDestinations()
.filter { it.from.chainId !in disabledChainIds && it.to.chainId !in disabledChainIds }
.map(::CrossChainTransferEdge)
}
override fun feePaymentOverrides(): List<FeePaymentProviderOverride> {
return emptyList()
}
override fun runSubscriptions(metaAccount: MetaAccount): Flow<ReQuoteTrigger> {
return emptyFlow()
}
inner class CrossChainTransferEdge(
val delegate: Edge<FullChainAssetId>
) : SwapGraphEdge, Edge<FullChainAssetId> by delegate {
private var canUseXcmExecute: Boolean? = null
override suspend fun beginOperation(args: AtomicSwapOperationArgs): AtomicSwapOperation {
return CrossChainTransferOperation(args, this)
}
override suspend fun appendToOperation(currentTransaction: AtomicSwapOperation, args: AtomicSwapOperationArgs): AtomicSwapOperation? {
return null
}
override suspend fun beginOperationPrototype(): AtomicSwapOperationPrototype {
return CrossChainTransferOperationPrototype(this)
}
override suspend fun appendToOperationPrototype(currentTransaction: AtomicSwapOperationPrototype): AtomicSwapOperationPrototype? {
return null
}
override suspend fun debugLabel(): String {
return "To ${chainRegistry.getChain(delegate.to.chainId).name}"
}
override fun predecessorHandlesFees(predecessor: SwapGraphEdge): Boolean {
return false
}
override suspend fun canPayNonNativeFeesInIntermediatePosition(): Boolean {
// By default, delivery fees are not payable in non native assets
return !hasDeliveryFees() ||
// ... but xcm execute allows to workaround it
canUseXcmExecute()
}
override suspend fun canTransferOutWholeAccountBalance(): Boolean {
// Precisely speaking just checking for delivery fees is not enough
// AssetTransactor on origin should also use Preserve transfers when executing TransferAssets instruction
// However it is much harder to check and there are no chains yet that have limitations on AssetTransactor level
// but don't have delivery fees, so we only check for delivery fees
return !hasDeliveryFees() ||
// When direction has delivery fees, xcm execute can be used to pay them from holding, thus allowing to transfer whole balance
// and also workaround AssetTransactor issue as "Withdraw" instruction doesn't use Preserve transfers but rather use burn
canUseXcmExecute()
}
override suspend fun quote(amount: BigInteger, direction: SwapDirection): BigInteger {
return amount
}
override fun weightForAppendingTo(path: Path<WeightedEdge<FullChainAssetId>>): Int {
return Weights.CrossChainTransfer.TRANSFER
}
private suspend fun canUseXcmExecute(): Boolean {
if (canUseXcmExecute == null) {
canUseXcmExecute = calculateCanUseXcmExecute()
}
return canUseXcmExecute!!
}
private fun hasDeliveryFees(): Boolean {
val config = crossChainConfig.value ?: return false
return config.hasDeliveryFee(delegate.from, delegate.to)
}
private suspend fun calculateCanUseXcmExecute(): Boolean {
val features = crossChainConfig.value?.dynamic?.transferFeatures(delegate.from, delegate.to.chainId) ?: return false
return crossChainTransfersUseCase.supportsXcmExecute(delegate.from.chainId, features)
}
}
inner class CrossChainTransferOperationPrototype(
private val edge: Edge<FullChainAssetId>,
) : AtomicSwapOperationPrototype {
override val fromChain: ChainId = edge.from.chainId
private val toChain: ChainId = edge.to.chainId
override suspend fun roughlyEstimateNativeFee(usdConverter: UsdConverter): BigDecimal {
var totalAmount = BigDecimal.ZERO
if (isChainWithExpensiveCrossChain(fromChain)) {
totalAmount += usdConverter.nativeAssetEquivalentOf(0.15)
}
if (isChainWithExpensiveCrossChain(toChain)) {
totalAmount += usdConverter.nativeAssetEquivalentOf(0.1)
}
if (!(isChainWithExpensiveCrossChain(fromChain) || isChainWithExpensiveCrossChain(toChain))) {
totalAmount += usdConverter.nativeAssetEquivalentOf(0.01)
}
return totalAmount
}
override suspend fun maximumExecutionTime(): Duration {
val (fromChain, fromAsset) = chainRegistry.chainWithAsset(edge.from)
val (toChain, toAsset) = chainRegistry.chainWithAsset(edge.to)
val transferDirection = AssetTransferDirection(fromChain, fromAsset, toChain, toAsset)
return crossChainTransfersUseCase.maximumExecutionTime(transferDirection, swapHost.scope)
}
private fun isChainWithExpensiveCrossChain(chainId: ChainId): Boolean {
return (chainId == Chain.Geneses.POLKADOT) or (chainId == Chain.Geneses.POLKADOT_ASSET_HUB)
}
}
inner class CrossChainTransferOperation(
private val transactionArgs: AtomicSwapOperationArgs,
private val edge: Edge<FullChainAssetId>
) : AtomicSwapOperation {
override val estimatedSwapLimit: SwapLimit = transactionArgs.estimatedSwapLimit
override val assetOut: FullChainAssetId = edge.to
override val assetIn: FullChainAssetId = edge.from
override suspend fun constructDisplayData(): AtomicOperationDisplayData {
return AtomicOperationDisplayData.Transfer(
from = edge.from,
to = edge.to,
amount = estimatedSwapLimit.estimatedAmountIn
)
}
override suspend fun estimateFee(): AtomicSwapOperationFee {
val transfer = createTransfer(amount = estimatedSwapLimit.crossChainTransferAmount)
val crossChainFee = with(crossChainTransfersUseCase) {
swapHost.extrinsicService().estimateFee(transfer, swapHost.scope)
}
return CrossChainAtomicOperationFee(crossChainFee)
}
override suspend fun requiredAmountInToGetAmountOut(extraOutAmount: Balance): Balance {
return extraOutAmount
}
override suspend fun additionalMaxAmountDeduction(): SwapMaxAdditionalAmountDeduction {
val (originChain, originChainAsset) = chainRegistry.chainWithAsset(edge.from)
val destinationChain = chainRegistry.getChain(edge.to.chainId)
return SwapMaxAdditionalAmountDeduction(
fromCountedTowardsEd = crossChainTransfersUseCase.requiredRemainingAmountAfterTransfer(originChain, originChainAsset, destinationChain)
)
}
override suspend fun execute(args: AtomicSwapOperationSubmissionArgs): Result<SwapExecutionCorrection> {
val transfer = createTransfer(amount = args.actualSwapLimit.crossChainTransferAmount)
return dryRunTransfer(transfer)
.flatMap { with(crossChainTransfersUseCase) { swapHost.extrinsicService().performTransferAndTrackTransfer(transfer, swapHost.scope) } }
.map(::SwapExecutionCorrection)
}
override suspend fun submit(args: AtomicSwapOperationSubmissionArgs): Result<SwapSubmissionResult> {
val transfer = createTransfer(amount = args.actualSwapLimit.crossChainTransferAmount)
return dryRunTransfer(transfer)
.flatMap { with(crossChainTransfersUseCase) { swapHost.extrinsicService().performTransferOfExactAmount(transfer, swapHost.scope) } }
.map { SwapSubmissionResult(it.submissionHierarchy) }
}
private suspend fun dryRunTransfer(transfer: AssetTransferBase): Result<Unit> {
val metaAccount = accountRepository.getSelectedMetaAccount()
val origin = metaAccount.requireAccountIdKeyIn(transfer.originChain)
return crossChainTransfersUseCase.dryRunTransferIfPossible(
transfer = transfer,
// We are transferring exact amount, so we use zero for the fee here
origin = XcmTransferDryRunOrigin.Signed(origin, crossChainFee = Balance.ZERO),
computationalScope = swapHost.scope
)
.mapError { SwapOperationSubmissionException.SimulationFailed() }
}
private suspend fun createTransfer(amount: Balance): AssetTransferBase {
val (originChain, originAsset) = chainRegistry.chainWithAsset(edge.from)
val (destinationChain, destinationAsset) = chainRegistry.chainWithAsset(edge.to)
val selectedAccount = accountRepository.getSelectedMetaAccount()
return AssetTransferBase(
recipient = selectedAccount.requireAddressIn(destinationChain),
originChain = originChain,
originChainAsset = originAsset,
destinationChain = destinationChain,
destinationChainAsset = destinationAsset,
feePaymentCurrency = transactionArgs.feePaymentCurrency,
amountPlanks = amount
)
}
private val SwapLimit.crossChainTransferAmount: Balance
get() = when (this) {
is SwapLimit.SpecifiedIn -> amountIn
is SwapLimit.SpecifiedOut -> amountOut
}
}
private class CrossChainAtomicOperationFee(
private val crossChainFee: CrossChainTransferFee
) : AtomicSwapOperationFee {
override val submissionFee = SubmissionFeeWithLabel(crossChainFee.submissionFee)
override val postSubmissionFees = AtomicSwapOperationFee.PostSubmissionFees(
paidByAccount = listOfNotNull(
SubmissionFeeWithLabel(crossChainFee.postSubmissionByAccount, debugLabel = "Delivery"),
),
paidFromAmount = listOf(
FeeWithLabel(crossChainFee.postSubmissionFromAmount, debugLabel = "Execution")
)
)
override fun constructDisplayData(): AtomicOperationFeeDisplayData {
val deliveryFee = crossChainFee.postSubmissionByAccount
val shouldSeparateDeliveryFromExecution = deliveryFee != null && deliveryFee.asset.fullId != crossChainFee.postSubmissionFromAmount.asset.fullId
val crossChainFeeComponentDisplay = if (shouldSeparateDeliveryFromExecution) {
SwapFeeComponentDisplay.crossChain(crossChainFee.postSubmissionFromAmount, deliveryFee!!)
} else {
val totalCrossChain = crossChainFee.postSubmissionFromAmount.addPlanks(deliveryFee?.amount.orZero())
SwapFeeComponentDisplay.crossChain(totalCrossChain)
}
val submissionFeeComponent = SwapFeeComponentDisplay.network(crossChainFee.submissionFee)
val components = listOf(submissionFeeComponent, crossChainFeeComponentDisplay)
return AtomicOperationFeeDisplayData(components)
}
}
}
@@ -0,0 +1,607 @@
package io.novafoundation.nova.feature_swap_impl.data.assetExchange.hydraDx
import io.novafoundation.nova.common.data.network.runtime.binding.bindNumber
import io.novafoundation.nova.common.utils.Modules
import io.novafoundation.nova.common.utils.flatMapAsync
import io.novafoundation.nova.common.utils.forEachAsync
import io.novafoundation.nova.common.utils.mergeIfMultiple
import io.novafoundation.nova.common.utils.metadata
import io.novafoundation.nova.common.utils.singleReplaySharedFlow
import io.novafoundation.nova.common.utils.structOf
import io.novafoundation.nova.common.utils.times
import io.novafoundation.nova.common.utils.withFlowScope
import io.novafoundation.nova.feature_account_api.data.ethereum.transaction.TransactionOrigin
import io.novafoundation.nova.feature_account_api.data.extrinsic.ExtrinsicService
import io.novafoundation.nova.feature_account_api.data.extrinsic.execution.ExtrinsicExecutionResult
import io.novafoundation.nova.feature_account_api.data.extrinsic.execution.requireOk
import io.novafoundation.nova.feature_account_api.data.extrinsic.execution.requireOutcomeOk
import io.novafoundation.nova.feature_account_api.data.fee.FeePayment
import io.novafoundation.nova.feature_account_api.data.fee.FeePaymentCurrency
import io.novafoundation.nova.feature_account_api.data.fee.capability.FastLookupCustomFeeCapability
import io.novafoundation.nova.feature_account_api.data.fee.chains.CustomOrNativeFeePaymentProvider
import io.novafoundation.nova.feature_account_api.data.fee.types.NativeFeePayment
import io.novafoundation.nova.feature_account_api.data.fee.types.hydra.HydrationFeeInjector
import io.novafoundation.nova.feature_account_api.data.fee.types.hydra.HydrationFeeInjector.ResetMode
import io.novafoundation.nova.feature_account_api.data.fee.types.hydra.HydrationFeeInjector.SetFeesMode
import io.novafoundation.nova.feature_account_api.data.fee.types.hydra.HydrationFeeInjector.SetMode
import io.novafoundation.nova.feature_account_api.data.model.Fee
import io.novafoundation.nova.feature_account_api.data.model.SubstrateFee
import io.novafoundation.nova.feature_account_api.domain.model.MetaAccount
import io.novafoundation.nova.feature_account_api.domain.model.requireAccountIdIn
import io.novafoundation.nova.feature_swap_api.domain.model.AtomicOperationDisplayData
import io.novafoundation.nova.feature_swap_api.domain.model.AtomicSwapOperation
import io.novafoundation.nova.feature_swap_api.domain.model.AtomicSwapOperationArgs
import io.novafoundation.nova.feature_swap_api.domain.model.AtomicSwapOperationPrototype
import io.novafoundation.nova.feature_swap_api.domain.model.AtomicSwapOperationSubmissionArgs
import io.novafoundation.nova.feature_swap_api.domain.model.ReQuoteTrigger
import io.novafoundation.nova.feature_swap_api.domain.model.SwapExecutionCorrection
import io.novafoundation.nova.feature_swap_api.domain.model.SwapGraphEdge
import io.novafoundation.nova.feature_swap_api.domain.model.SwapLimit
import io.novafoundation.nova.feature_swap_api.domain.model.SwapMaxAdditionalAmountDeduction
import io.novafoundation.nova.feature_swap_api.domain.model.SwapSubmissionResult
import io.novafoundation.nova.feature_swap_api.domain.model.UsdConverter
import io.novafoundation.nova.feature_swap_api.domain.model.createAggregated
import io.novafoundation.nova.feature_swap_api.domain.model.estimatedAmountIn
import io.novafoundation.nova.feature_swap_api.domain.model.estimatedAmountOut
import io.novafoundation.nova.feature_swap_api.domain.model.fee.AtomicSwapOperationFee
import io.novafoundation.nova.feature_swap_api.domain.model.fee.SubmissionOnlyAtomicSwapOperationFee
import io.novafoundation.nova.feature_swap_core.data.assetExchange.conversion.types.hydra.accountCurrencyMap
import io.novafoundation.nova.feature_swap_core.data.assetExchange.conversion.types.hydra.multiTransactionPayment
import io.novafoundation.nova.feature_swap_core.data.assetExchange.conversion.types.hydra.sources.HydraDxQuotableEdge
import io.novafoundation.nova.feature_swap_core_api.data.network.HydraDxAssetId
import io.novafoundation.nova.feature_swap_core_api.data.network.HydraDxAssetIdConverter
import io.novafoundation.nova.feature_swap_core_api.data.network.toOnChainIdOrThrow
import io.novafoundation.nova.feature_swap_core_api.data.primitive.model.SwapDirection
import io.novafoundation.nova.feature_swap_core_api.data.types.hydra.HydraDxQuoting
import io.novafoundation.nova.feature_swap_core_api.data.types.hydra.HydraDxQuotingSource
import io.novafoundation.nova.feature_swap_core_api.data.types.hydra.HydrationAcceptedFeeCurrenciesFetcher
import io.novafoundation.nova.feature_swap_core_api.data.types.hydra.HydrationPriceConversionFallback
import io.novafoundation.nova.feature_swap_impl.data.assetExchange.AssetExchange
import io.novafoundation.nova.feature_swap_impl.data.assetExchange.FeePaymentProviderOverride
import io.novafoundation.nova.feature_swap_impl.data.assetExchange.ParentQuoterArgs
import io.novafoundation.nova.feature_swap_impl.data.assetExchange.hydraDx.referrals.HydraDxNovaReferral
import io.novafoundation.nova.feature_swap_impl.data.assetExchange.hydraDx.referrals.linkedAccounts
import io.novafoundation.nova.feature_swap_impl.data.assetExchange.hydraDx.referrals.referralsOrNull
import io.novafoundation.nova.feature_swap_impl.domain.AssetInAdditionalSwapDeductionUseCase
import io.novafoundation.nova.feature_wallet_api.data.network.blockhain.types.Balance
import io.novafoundation.nova.feature_wallet_api.domain.model.withAmount
import io.novafoundation.nova.runtime.ethereum.StorageSharedRequestsBuilder
import io.novafoundation.nova.runtime.ethereum.StorageSharedRequestsBuilderFactory
import io.novafoundation.nova.runtime.ext.utilityAsset
import io.novafoundation.nova.runtime.multiNetwork.chain.model.Chain
import io.novafoundation.nova.runtime.multiNetwork.chain.model.ChainAssetId
import io.novafoundation.nova.runtime.multiNetwork.chain.model.ChainId
import io.novafoundation.nova.runtime.multiNetwork.chain.model.FullChainAssetId
import io.novafoundation.nova.runtime.multiNetwork.runtime.repository.findEvent
import io.novafoundation.nova.runtime.multiNetwork.runtime.repository.findEventOrThrow
import io.novafoundation.nova.runtime.repository.ChainStateRepository
import io.novafoundation.nova.runtime.repository.expectedBlockTime
import io.novafoundation.nova.runtime.storage.source.StorageDataSource
import io.novasama.substrate_sdk_android.runtime.AccountId
import io.novasama.substrate_sdk_android.runtime.definitions.types.generics.GenericEvent
import io.novasama.substrate_sdk_android.runtime.extrinsic.BatchMode
import io.novasama.substrate_sdk_android.runtime.extrinsic.builder.ExtrinsicBuilder
import io.novasama.substrate_sdk_android.runtime.extrinsic.call
import io.novasama.substrate_sdk_android.runtime.extrinsic.signer.SendableExtrinsic
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.MutableSharedFlow
import kotlinx.coroutines.flow.combine
import kotlinx.coroutines.flow.first
import kotlinx.coroutines.flow.flowOf
import kotlinx.coroutines.flow.map
import kotlinx.coroutines.flow.onEach
import java.math.BigDecimal
import kotlin.time.Duration
class HydraDxExchangeFactory(
private val remoteStorageSource: StorageDataSource,
private val sharedRequestsBuilderFactory: StorageSharedRequestsBuilderFactory,
private val hydraDxAssetIdConverter: HydraDxAssetIdConverter,
private val hydraDxNovaReferral: HydraDxNovaReferral,
private val swapSourceFactories: Iterable<HydraDxSwapSource.Factory<*>>,
private val quotingFactory: HydraDxQuoting.Factory,
private val hydrationFeeInjector: HydrationFeeInjector,
private val chainStateRepository: ChainStateRepository,
private val swapDeductionUseCase: AssetInAdditionalSwapDeductionUseCase,
private val hydrationPriceConversionFallback: HydrationPriceConversionFallback,
private val hydrationAcceptedFeeCurrenciesFetcher: HydrationAcceptedFeeCurrenciesFetcher
) : AssetExchange.SingleChainFactory {
override suspend fun create(chain: Chain, swapHost: AssetExchange.SwapHost): AssetExchange {
return HydraDxAssetExchange(
remoteStorageSource = remoteStorageSource,
chain = chain,
storageSharedRequestsBuilderFactory = sharedRequestsBuilderFactory,
hydraDxAssetIdConverter = hydraDxAssetIdConverter,
hydraDxNovaReferral = hydraDxNovaReferral,
swapSourceFactories = swapSourceFactories,
swapHost = swapHost,
hydrationFeeInjector = hydrationFeeInjector,
delegate = quotingFactory.create(chain, swapHost),
chainStateRepository = chainStateRepository,
swapDeductionUseCase = swapDeductionUseCase,
hydrationPriceConversionFallback = hydrationPriceConversionFallback,
hydrationAcceptedFeeCurrenciesFetcher = hydrationAcceptedFeeCurrenciesFetcher
)
}
}
private const val ROUTE_EXECUTED_AMOUNT_OUT_IDX = 3
private const val FEE_QUOTE_BUFFER = 1.1
private class HydraDxAssetExchange(
private val delegate: HydraDxQuoting,
private val remoteStorageSource: StorageDataSource,
private val chain: Chain,
private val storageSharedRequestsBuilderFactory: StorageSharedRequestsBuilderFactory,
private val hydraDxAssetIdConverter: HydraDxAssetIdConverter,
private val hydraDxNovaReferral: HydraDxNovaReferral,
private val swapSourceFactories: Iterable<HydraDxSwapSource.Factory<*>>,
private val swapHost: AssetExchange.SwapHost,
private val hydrationFeeInjector: HydrationFeeInjector,
private val chainStateRepository: ChainStateRepository,
private val swapDeductionUseCase: AssetInAdditionalSwapDeductionUseCase,
private val hydrationPriceConversionFallback: HydrationPriceConversionFallback,
private val hydrationAcceptedFeeCurrenciesFetcher: HydrationAcceptedFeeCurrenciesFetcher
) : AssetExchange {
private val swapSources: List<HydraDxSwapSource> = createSources()
private val currentPaymentAsset: MutableSharedFlow<HydraDxAssetId> = singleReplaySharedFlow()
private val userReferralState: MutableSharedFlow<ReferralState> = singleReplaySharedFlow()
override suspend fun sync() {
return swapSources.forEachAsync { it.sync() }
}
override suspend fun availableDirectSwapConnections(): List<SwapGraphEdge> {
return swapSources.flatMapAsync { source ->
source.availableSwapDirections().map(::HydraDxSwapEdge)
}
}
override fun feePaymentOverrides(): List<FeePaymentProviderOverride> {
return listOf(
FeePaymentProviderOverride(
provider = ReusableQuoteFeePaymentProvider(),
chain = chain
)
)
}
override fun runSubscriptions(metaAccount: MetaAccount): Flow<ReQuoteTrigger> {
return withFlowScope { scope ->
val subscriptionBuilder = storageSharedRequestsBuilderFactory.create(chain.id)
val userAccountId = metaAccount.requireAccountIdIn(chain)
val feeCurrency = remoteStorageSource.subscribe(chain.id, subscriptionBuilder) {
metadata.multiTransactionPayment.accountCurrencyMap.observe(userAccountId)
}
val userReferral = subscribeUserReferral(userAccountId, subscriptionBuilder).onEach {
userReferralState.emit(it)
}
val sourcesSubscription = swapSources.map {
it.runSubscriptions(userAccountId, subscriptionBuilder)
}.mergeIfMultiple()
subscriptionBuilder.subscribe(scope)
val feeCurrencyUpdates = feeCurrency.onEach { tokenId ->
val feePaymentAsset = tokenId ?: hydraDxAssetIdConverter.systemAssetId
currentPaymentAsset.emit(feePaymentAsset)
}
combine(sourcesSubscription, feeCurrencyUpdates, userReferral) { _, _, _ ->
ReQuoteTrigger
}
}
}
@Suppress("IfThenToElvis")
private suspend fun subscribeUserReferral(
userAccountId: AccountId,
subscriptionBuilder: StorageSharedRequestsBuilder
): Flow<ReferralState> {
return remoteStorageSource.subscribe(chain.id, subscriptionBuilder) {
val referralsModule = metadata.referralsOrNull
if (referralsModule != null) {
referralsModule.linkedAccounts.observe(userAccountId).map { linkedAccount ->
if (linkedAccount != null) ReferralState.SET else ReferralState.NOT_SET
}
} else {
flowOf(ReferralState.NOT_AVAILABLE)
}
}
}
private suspend fun HydraDxAssetIdConverter.toOnChainIdOrThrow(localId: FullChainAssetId): HydraDxAssetId {
val chainAsset = chain.assetsById.getValue(localId.assetId)
return toOnChainIdOrThrow(chainAsset)
}
private enum class ReferralState {
SET, NOT_SET, NOT_AVAILABLE
}
@Suppress("UNCHECKED_CAST")
private fun createSources(): List<HydraDxSwapSource> {
return swapSourceFactories.map {
val sourceDelegate = delegate.getSource(it.identifier)
// Cast should be safe as long as identifiers between delegates and wrappers match
(it as HydraDxSwapSource.Factory<HydraDxQuotingSource<*>>).create(sourceDelegate)
}
}
private inner class HydraDxSwapEdge(
private val sourceQuotableEdge: HydraDxSourceEdge,
) : SwapGraphEdge, HydraDxQuotableEdge by sourceQuotableEdge {
override suspend fun beginOperation(args: AtomicSwapOperationArgs): AtomicSwapOperation {
return HydraDxOperation(sourceQuotableEdge, args)
}
override suspend fun appendToOperation(currentTransaction: AtomicSwapOperation, args: AtomicSwapOperationArgs): AtomicSwapOperation? {
if (currentTransaction !is HydraDxOperation) return null
return currentTransaction.appendSegment(sourceQuotableEdge, args)
}
override suspend fun beginOperationPrototype(): AtomicSwapOperationPrototype {
return HydraDxOperationPrototype(from.chainId)
}
override suspend fun appendToOperationPrototype(currentTransaction: AtomicSwapOperationPrototype): AtomicSwapOperationPrototype? {
return if (currentTransaction is HydraDxOperationPrototype) {
currentTransaction
} else {
null
}
}
override suspend fun debugLabel(): String {
return sourceQuotableEdge.debugLabel()
}
override fun predecessorHandlesFees(predecessor: SwapGraphEdge): Boolean {
// When chaining multiple hydra edges together, the fee is always paid with the starting edge
return predecessor is HydraDxSwapEdge
}
override suspend fun canPayNonNativeFeesInIntermediatePosition(): Boolean {
return true
}
override suspend fun canTransferOutWholeAccountBalance(): Boolean {
return true
}
}
inner class HydraDxOperationPrototype(override val fromChain: ChainId) : AtomicSwapOperationPrototype {
override suspend fun roughlyEstimateNativeFee(usdConverter: UsdConverter): BigDecimal {
// in HDX
return 0.5.toBigDecimal()
}
override suspend fun maximumExecutionTime(): Duration {
return chainStateRepository.expectedBlockTime(chain.id)
}
}
inner class HydraDxOperation private constructor(
val segments: List<HydraDxSwapTransactionSegment>,
val feePaymentCurrency: FeePaymentCurrency
) : AtomicSwapOperation {
override val estimatedSwapLimit: SwapLimit = aggregatedSwapLimit()
override val assetOut: FullChainAssetId = segments.last().edge.to
override val assetIn: FullChainAssetId = segments.first().edge.from
constructor(sourceEdge: HydraDxSourceEdge, args: AtomicSwapOperationArgs) :
this(listOf(HydraDxSwapTransactionSegment(sourceEdge, args.estimatedSwapLimit)), args.feePaymentCurrency)
fun appendSegment(nextEdge: HydraDxSourceEdge, nextSwapArgs: AtomicSwapOperationArgs): HydraDxOperation {
val nextSegment = HydraDxSwapTransactionSegment(nextEdge, nextSwapArgs.estimatedSwapLimit)
// Ignore nextSwapArgs.feePaymentCurrency - we are using configuration from the very first segment
return HydraDxOperation(segments + nextSegment, feePaymentCurrency)
}
override suspend fun constructDisplayData(): AtomicOperationDisplayData {
return AtomicOperationDisplayData.Swap(
from = assetIn.withAmount(estimatedSwapLimit.estimatedAmountIn),
to = assetOut.withAmount(estimatedSwapLimit.estimatedAmountOut),
)
}
override suspend fun estimateFee(): AtomicSwapOperationFee {
val submissionFee = swapHost.extrinsicService().estimateFee(
chain = chain,
origin = TransactionOrigin.SelectedWallet,
submissionOptions = ExtrinsicService.SubmissionOptions(
batchMode = BatchMode.BATCH_ALL,
feePaymentCurrency = feePaymentCurrency
)
) {
executeSwap(estimatedSwapLimit)
}
return SubmissionOnlyAtomicSwapOperationFee(submissionFee)
}
override suspend fun requiredAmountInToGetAmountOut(extraOutAmount: Balance): Balance {
val assetInId = assetIn.assetId
val assetIn = chain.assetsById.getValue(assetInId)
val assetOutId = assetOut.assetId
val assetOut = chain.assetsById.getValue(assetOutId)
val quoteArgs = ParentQuoterArgs(
chainAssetIn = assetIn,
chainAssetOut = assetOut,
amount = extraOutAmount,
swapDirection = SwapDirection.SPECIFIED_OUT
)
return swapHost.quote(quoteArgs)
}
override suspend fun additionalMaxAmountDeduction(): SwapMaxAdditionalAmountDeduction {
val assetInId = assetIn.assetId
val assetIn = chain.assetsById.getValue(assetInId)
val assetOutId = assetOut.assetId
val assetOut = chain.assetsById.getValue(assetOutId)
return SwapMaxAdditionalAmountDeduction(
fromCountedTowardsEd = swapDeductionUseCase.invoke(assetIn, assetOut)
)
}
override suspend fun execute(args: AtomicSwapOperationSubmissionArgs): Result<SwapExecutionCorrection> {
return submitInternal(args)
.mapCatching {
SwapExecutionCorrection(
actualReceivedAmount = it.requireOutcomeOk().emittedEvents.determineActualSwappedAmount()
)
}
}
override suspend fun submit(args: AtomicSwapOperationSubmissionArgs): Result<SwapSubmissionResult> {
return submitInternal(args)
.map { SwapSubmissionResult(it.submissionHierarchy) }
}
private suspend fun submitInternal(args: AtomicSwapOperationSubmissionArgs): Result<ExtrinsicExecutionResult> {
return swapHost.extrinsicService().submitExtrinsicAndAwaitExecution(
chain = chain,
origin = TransactionOrigin.SelectedWallet,
submissionOptions = ExtrinsicService.SubmissionOptions(
batchMode = BatchMode.BATCH_ALL,
feePaymentCurrency = feePaymentCurrency
)
) {
executeSwap(args.actualSwapLimit)
}.requireOk()
}
private fun List<GenericEvent.Instance>.determineActualSwappedAmount(): Balance {
val standaloneHydraSwap = getStandaloneSwap()
if (standaloneHydraSwap != null) {
return standaloneHydraSwap.extractReceivedAmount(this)
}
val swapExecutedEvent = findEvent(Modules.ROUTER, "RouteExecuted")
?: findEventOrThrow(Modules.ROUTER, "Executed")
val amountOut = swapExecutedEvent.arguments[ROUTE_EXECUTED_AMOUNT_OUT_IDX]
return bindNumber(amountOut)
}
private suspend fun ExtrinsicBuilder.executeSwap(actualSwapLimit: SwapLimit) {
maybeSetReferral()
addSwapCall(actualSwapLimit)
}
private suspend fun ExtrinsicBuilder.addSwapCall(actualSwapLimit: SwapLimit) {
val optimizationSucceeded = tryOptimizedSwap(actualSwapLimit)
if (!optimizationSucceeded) {
executeRouterSwap(actualSwapLimit)
}
}
private fun ExtrinsicBuilder.tryOptimizedSwap(actualSwapLimit: SwapLimit): Boolean {
val standaloneSwap = getStandaloneSwap() ?: return false
val args = AtomicSwapOperationArgs(actualSwapLimit, feePaymentCurrency)
standaloneSwap.addSwapCall(args)
return true
}
private fun getStandaloneSwap(): StandaloneHydraSwap? {
if (segments.size != 1) return null
val onlySegment = segments.single()
return onlySegment.edge.standaloneSwap
}
private suspend fun ExtrinsicBuilder.executeRouterSwap(actualSwapLimit: SwapLimit) {
val firstSegment = segments.first()
val lastSegment = segments.last()
when (actualSwapLimit) {
is SwapLimit.SpecifiedIn -> executeRouterSell(
firstEdge = firstSegment.edge,
lastEdge = lastSegment.edge,
limit = actualSwapLimit,
)
is SwapLimit.SpecifiedOut -> executeRouterBuy(
firstEdge = firstSegment.edge,
lastEdge = lastSegment.edge,
limit = actualSwapLimit,
)
}
}
private suspend fun ExtrinsicBuilder.executeRouterBuy(
firstEdge: HydraDxSourceEdge,
lastEdge: HydraDxSourceEdge,
limit: SwapLimit.SpecifiedOut,
) {
call(
moduleName = Modules.ROUTER,
callName = "buy",
arguments = mapOf(
"asset_in" to hydraDxAssetIdConverter.toOnChainIdOrThrow(firstEdge.from),
"asset_out" to hydraDxAssetIdConverter.toOnChainIdOrThrow(lastEdge.to),
"amount_out" to limit.amountOut,
"max_amount_in" to limit.amountInMax,
"route" to routerTradePath()
)
)
}
private suspend fun ExtrinsicBuilder.executeRouterSell(
firstEdge: HydraDxSourceEdge,
lastEdge: HydraDxSourceEdge,
limit: SwapLimit.SpecifiedIn,
) {
call(
moduleName = Modules.ROUTER,
callName = "sell",
arguments = mapOf(
"asset_in" to hydraDxAssetIdConverter.toOnChainIdOrThrow(firstEdge.from),
"asset_out" to hydraDxAssetIdConverter.toOnChainIdOrThrow(lastEdge.to),
"amount_in" to limit.amountIn,
"min_amount_out" to limit.amountOutMin,
"route" to routerTradePath()
)
)
}
private suspend fun routerTradePath(): List<Any?> {
return segments.map { segment ->
structOf(
"pool" to segment.edge.routerPoolArgument(),
"assetIn" to hydraDxAssetIdConverter.toOnChainIdOrThrow(segment.edge.from),
"assetOut" to hydraDxAssetIdConverter.toOnChainIdOrThrow(segment.edge.to)
)
}
}
private suspend fun ExtrinsicBuilder.maybeSetReferral() {
val referralState = userReferralState.first()
if (referralState == ReferralState.NOT_SET) {
val novaReferralCode = hydraDxNovaReferral.getNovaReferralCode()
linkCode(novaReferralCode)
}
}
private fun ExtrinsicBuilder.linkCode(referralCode: String) {
call(
moduleName = Modules.REFERRALS,
callName = "link_code",
arguments = mapOf(
"code" to referralCode.encodeToByteArray()
)
)
}
private fun aggregatedSwapLimit(): SwapLimit {
val firstLimit = segments.first().swapLimit
val lastLimit = segments.last().swapLimit
return SwapLimit.createAggregated(firstLimit, lastLimit)
}
}
// This is an optimization to reuse swap quoting state for hydra fee estimation instead of letting ExtrinsicService to spin up its own quoting
private inner class ReusableQuoteFeePaymentProvider() : CustomOrNativeFeePaymentProvider() {
override val chain: Chain = this@HydraDxAssetExchange.chain
override suspend fun feePaymentFor(customFeeAsset: Chain.Asset, coroutineScope: CoroutineScope?): FeePayment {
return ReusableQuoteFeePayment(customFeeAsset)
}
override suspend fun canPayFeeInNonUtilityToken(customFeeAsset: Chain.Asset): Result<Boolean> {
return hydrationAcceptedFeeCurrenciesFetcher.isAcceptedCurrency(customFeeAsset)
}
override suspend fun detectFeePaymentFromExtrinsic(extrinsic: SendableExtrinsic): FeePayment {
// Todo Hydration fee support from extrinsic
return NativeFeePayment()
}
override suspend fun fastLookupCustomFeeCapability(): Result<FastLookupCustomFeeCapability> {
return runCatching {
val acceptedCurrencies = hydrationAcceptedFeeCurrenciesFetcher.fetchAcceptedFeeCurrencies(chain)
.getOrDefault(emptySet())
HydrationFastLookupFeeCapability(acceptedCurrencies)
}
}
}
private inner class ReusableQuoteFeePayment(
private val customFeeAsset: Chain.Asset
) : FeePayment {
override suspend fun modifyExtrinsic(extrinsicBuilder: ExtrinsicBuilder) {
val currentFeeTokenId = currentPaymentAsset.first()
val setFeesMode = SetFeesMode(
setMode = SetMode.Lazy(currentFeeTokenId),
resetMode = ResetMode.ToNativeLazily(currentFeeTokenId)
)
hydrationFeeInjector.setFees(extrinsicBuilder, customFeeAsset, setFeesMode)
}
override suspend fun convertNativeFee(nativeFee: Fee): Fee {
val args = ParentQuoterArgs(
chainAssetIn = customFeeAsset,
chainAssetOut = chain.utilityAsset,
amount = nativeFee.amount,
swapDirection = SwapDirection.SPECIFIED_OUT
)
val quotedFee = runCatching { swapHost.quote(args) }
.recoverCatching { hydrationPriceConversionFallback.convertNativeAmount(nativeFee.amount, customFeeAsset) }
.getOrThrow()
// Fees in non-native assets are especially volatile since conversion happens through swaps so we add some buffer to mitigate volatility
val quotedFeeWithBuffer = quotedFee * FEE_QUOTE_BUFFER
return SubstrateFee(
amount = quotedFeeWithBuffer,
submissionOrigin = nativeFee.submissionOrigin,
asset = customFeeAsset
)
}
}
private inner class HydrationFastLookupFeeCapability(
override val nonUtilityFeeCapableTokens: Set<ChainAssetId>
) : FastLookupCustomFeeCapability
class HydraDxSwapTransactionSegment(
val edge: HydraDxSourceEdge,
val swapLimit: SwapLimit,
)
}
@@ -0,0 +1,50 @@
package io.novafoundation.nova.feature_swap_impl.data.assetExchange.hydraDx
import io.novafoundation.nova.common.utils.Identifiable
import io.novafoundation.nova.core.updater.SharedRequestsBuilder
import io.novafoundation.nova.feature_swap_api.domain.model.AtomicSwapOperationArgs
import io.novafoundation.nova.feature_swap_core.data.assetExchange.conversion.types.hydra.sources.HydraDxQuotableEdge
import io.novafoundation.nova.feature_swap_core_api.data.types.hydra.HydraDxQuotingSource
import io.novafoundation.nova.feature_wallet_api.data.network.blockhain.types.Balance
import io.novasama.substrate_sdk_android.runtime.AccountId
import io.novasama.substrate_sdk_android.runtime.definitions.types.composite.DictEnum
import io.novasama.substrate_sdk_android.runtime.definitions.types.generics.GenericEvent
import io.novasama.substrate_sdk_android.runtime.extrinsic.builder.ExtrinsicBuilder
import kotlinx.coroutines.flow.Flow
interface StandaloneHydraSwap {
context(ExtrinsicBuilder)
fun addSwapCall(args: AtomicSwapOperationArgs)
fun extractReceivedAmount(events: List<GenericEvent.Instance>): Balance
}
interface HydraDxSourceEdge : HydraDxQuotableEdge {
fun routerPoolArgument(): DictEnum.Entry<*>
/**
* Whether hydra swap source is able to perform optimized standalone swap without using Router
*/
val standaloneSwap: StandaloneHydraSwap?
suspend fun debugLabel(): String
}
interface HydraDxSwapSource : Identifiable {
suspend fun sync()
suspend fun availableSwapDirections(): Collection<HydraDxSourceEdge>
suspend fun runSubscriptions(
userAccountId: AccountId,
subscriptionBuilder: SharedRequestsBuilder
): Flow<Unit>
interface Factory<D : HydraDxQuotingSource<*>> : Identifiable {
fun create(delegate: D): HydraDxSwapSource
}
}
@@ -0,0 +1,59 @@
package io.novafoundation.nova.feature_swap_impl.data.assetExchange.hydraDx.aave
import io.novafoundation.nova.common.di.scope.FeatureScope
import io.novafoundation.nova.common.utils.Identifiable
import io.novafoundation.nova.core.updater.SharedRequestsBuilder
import io.novafoundation.nova.feature_swap_core.data.assetExchange.conversion.types.hydra.sources.aave.AavePoolQuotingSource
import io.novafoundation.nova.feature_swap_core.data.assetExchange.conversion.types.hydra.sources.aave.AaveSwapQuotingSourceFactory
import io.novafoundation.nova.feature_swap_core_api.data.primitive.model.QuotableEdge
import io.novafoundation.nova.feature_swap_impl.data.assetExchange.hydraDx.HydraDxSourceEdge
import io.novafoundation.nova.feature_swap_impl.data.assetExchange.hydraDx.HydraDxSwapSource
import io.novasama.substrate_sdk_android.runtime.AccountId
import io.novasama.substrate_sdk_android.runtime.definitions.types.composite.DictEnum
import kotlinx.coroutines.flow.Flow
import javax.inject.Inject
@FeatureScope
class AaveSwapSourceFactory @Inject constructor() : HydraDxSwapSource.Factory<AavePoolQuotingSource> {
override val identifier: String = AaveSwapQuotingSourceFactory.ID
override fun create(delegate: AavePoolQuotingSource): HydraDxSwapSource {
return AaveSwapSource(delegate)
}
}
private class AaveSwapSource(
private val delegate: AavePoolQuotingSource,
) : HydraDxSwapSource, Identifiable by delegate {
override suspend fun sync() {
return delegate.sync()
}
override suspend fun availableSwapDirections(): Collection<HydraDxSourceEdge> {
return delegate.availableSwapDirections().map(::AaveSwapEdge)
}
override suspend fun runSubscriptions(
userAccountId: AccountId,
subscriptionBuilder: SharedRequestsBuilder
): Flow<Unit> {
return delegate.runSubscriptions(userAccountId, subscriptionBuilder)
}
inner class AaveSwapEdge(
private val delegate: AavePoolQuotingSource.Edge
) : HydraDxSourceEdge, QuotableEdge by delegate {
override fun routerPoolArgument(): DictEnum.Entry<*> {
return DictEnum.Entry("Aave", null)
}
override val standaloneSwap = null
override suspend fun debugLabel(): String {
return "Aave"
}
}
}
@@ -0,0 +1,136 @@
package io.novafoundation.nova.feature_swap_impl.data.assetExchange.hydraDx.omnipool
import io.novafoundation.nova.common.data.network.runtime.binding.bindNumber
import io.novafoundation.nova.common.utils.Identifiable
import io.novafoundation.nova.common.utils.Modules
import io.novafoundation.nova.core.updater.SharedRequestsBuilder
import io.novafoundation.nova.feature_swap_api.domain.model.AtomicSwapOperationArgs
import io.novafoundation.nova.feature_swap_api.domain.model.SwapLimit
import io.novafoundation.nova.feature_swap_core.data.assetExchange.conversion.types.hydra.sources.omnipool.OmniPoolQuotingSource
import io.novafoundation.nova.feature_swap_core.data.assetExchange.conversion.types.hydra.sources.omnipool.OmniPoolQuotingSourceFactory
import io.novafoundation.nova.feature_swap_core_api.data.network.HydraDxAssetId
import io.novafoundation.nova.feature_swap_core_api.data.primitive.model.QuotableEdge
import io.novafoundation.nova.feature_swap_impl.data.assetExchange.hydraDx.HydraDxSourceEdge
import io.novafoundation.nova.feature_swap_impl.data.assetExchange.hydraDx.HydraDxSwapSource
import io.novafoundation.nova.feature_swap_impl.data.assetExchange.hydraDx.StandaloneHydraSwap
import io.novafoundation.nova.feature_wallet_api.data.network.blockhain.types.Balance
import io.novafoundation.nova.runtime.multiNetwork.runtime.repository.findEvent
import io.novafoundation.nova.runtime.multiNetwork.runtime.repository.findEventOrThrow
import io.novasama.substrate_sdk_android.runtime.AccountId
import io.novasama.substrate_sdk_android.runtime.definitions.types.composite.DictEnum
import io.novasama.substrate_sdk_android.runtime.definitions.types.generics.GenericEvent
import io.novasama.substrate_sdk_android.runtime.extrinsic.builder.ExtrinsicBuilder
import io.novasama.substrate_sdk_android.runtime.extrinsic.call
import kotlinx.coroutines.flow.Flow
private const val AMOUNT_OUT_POSITION = 4
class OmniPoolSwapSourceFactory : HydraDxSwapSource.Factory<OmniPoolQuotingSource> {
override val identifier: String = OmniPoolQuotingSourceFactory.SOURCE_ID
override fun create(delegate: OmniPoolQuotingSource): HydraDxSwapSource {
return OmniPoolSwapSource(delegate)
}
}
private class OmniPoolSwapSource(
private val delegate: OmniPoolQuotingSource,
) : HydraDxSwapSource, Identifiable by delegate {
override suspend fun sync() {
return delegate.sync()
}
override suspend fun availableSwapDirections(): Collection<HydraDxSourceEdge> {
return delegate.availableSwapDirections().map(::OmniPoolSwapEdge)
}
override suspend fun runSubscriptions(
userAccountId: AccountId,
subscriptionBuilder: SharedRequestsBuilder
): Flow<Unit> {
return delegate.runSubscriptions(userAccountId, subscriptionBuilder)
}
private inner class OmniPoolSwapEdge(
private val delegate: OmniPoolQuotingSource.Edge
) : HydraDxSourceEdge, QuotableEdge by delegate, StandaloneHydraSwap {
override fun routerPoolArgument(): DictEnum.Entry<*> {
return DictEnum.Entry("Omnipool", null)
}
override val standaloneSwap = this
override suspend fun debugLabel(): String {
return "OmniPool"
}
context(ExtrinsicBuilder)
override fun addSwapCall(args: AtomicSwapOperationArgs) {
val assetIdIn = delegate.fromAsset.first
val assetIdOut = delegate.toAsset.first
when (val limit = args.estimatedSwapLimit) {
is SwapLimit.SpecifiedIn -> sell(
assetIdIn = assetIdIn,
assetIdOut = assetIdOut,
amountIn = limit.amountIn,
minBuyAmount = limit.amountOutMin
)
is SwapLimit.SpecifiedOut -> buy(
assetIdIn = assetIdIn,
assetIdOut = assetIdOut,
amountOut = limit.amountOut,
maxSellAmount = limit.amountInMax
)
}
}
override fun extractReceivedAmount(events: List<GenericEvent.Instance>): Balance {
val swapExecutedEvent = events.findEvent(Modules.OMNIPOOL, "BuyExecuted")
?: events.findEventOrThrow(Modules.OMNIPOOL, "SellExecuted")
val amountOut = swapExecutedEvent.arguments[AMOUNT_OUT_POSITION]
return bindNumber(amountOut)
}
private fun ExtrinsicBuilder.sell(
assetIdIn: HydraDxAssetId,
assetIdOut: HydraDxAssetId,
amountIn: Balance,
minBuyAmount: Balance
) {
call(
moduleName = Modules.OMNIPOOL,
callName = "sell",
arguments = mapOf(
"asset_in" to assetIdIn,
"asset_out" to assetIdOut,
"amount" to amountIn,
"min_buy_amount" to minBuyAmount
)
)
}
private fun ExtrinsicBuilder.buy(
assetIdIn: HydraDxAssetId,
assetIdOut: HydraDxAssetId,
amountOut: Balance,
maxSellAmount: Balance
) {
call(
moduleName = Modules.OMNIPOOL,
callName = "buy",
arguments = mapOf(
"asset_out" to assetIdOut,
"asset_in" to assetIdIn,
"amount" to amountOut,
"max_sell_amount" to maxSellAmount
)
)
}
}
}
@@ -0,0 +1,13 @@
package io.novafoundation.nova.feature_swap_impl.data.assetExchange.hydraDx.referrals
interface HydraDxNovaReferral {
fun getNovaReferralCode(): String
}
class RealHydraDxNovaReferral : HydraDxNovaReferral {
override fun getNovaReferralCode(): String {
return "NOVA"
}
}
@@ -0,0 +1,27 @@
@file:Suppress("RedundantUnitExpression")
package io.novafoundation.nova.feature_swap_impl.data.assetExchange.hydraDx.referrals
import io.novafoundation.nova.common.data.network.runtime.binding.bindAccountId
import io.novafoundation.nova.common.utils.referralsOrNull
import io.novafoundation.nova.runtime.storage.source.query.StorageQueryContext
import io.novafoundation.nova.runtime.storage.source.query.api.QueryableModule
import io.novafoundation.nova.runtime.storage.source.query.api.QueryableStorageEntry1
import io.novafoundation.nova.runtime.storage.source.query.api.storage1
import io.novasama.substrate_sdk_android.runtime.AccountId
import io.novasama.substrate_sdk_android.runtime.metadata.RuntimeMetadata
import io.novasama.substrate_sdk_android.runtime.metadata.module.Module
@JvmInline
value class ReferralsApi(override val module: Module) : QueryableModule
context(StorageQueryContext)
val RuntimeMetadata.referralsOrNull: ReferralsApi?
get() = referralsOrNull()?.let(::ReferralsApi)
context(StorageQueryContext)
val ReferralsApi.linkedAccounts: QueryableStorageEntry1<AccountId, AccountId>
get() = storage1(
name = "LinkedAccounts",
binding = { decoded, _ -> bindAccountId(decoded) },
)
@@ -0,0 +1,67 @@
package io.novafoundation.nova.feature_swap_impl.data.assetExchange.hydraDx.stableswap
import io.novafoundation.nova.common.utils.Identifiable
import io.novafoundation.nova.core.updater.SharedRequestsBuilder
import io.novafoundation.nova.feature_swap_core.data.assetExchange.conversion.types.hydra.sources.stableswap.StableSwapQuotingSource
import io.novafoundation.nova.feature_swap_core.data.assetExchange.conversion.types.hydra.sources.stableswap.StableSwapQuotingSourceFactory
import io.novafoundation.nova.feature_swap_core_api.data.network.HydraDxAssetIdConverter
import io.novafoundation.nova.feature_swap_core_api.data.network.toChainAssetOrThrow
import io.novafoundation.nova.feature_swap_core_api.data.primitive.model.QuotableEdge
import io.novafoundation.nova.feature_swap_impl.data.assetExchange.hydraDx.HydraDxSourceEdge
import io.novafoundation.nova.feature_swap_impl.data.assetExchange.hydraDx.HydraDxSwapSource
import io.novasama.substrate_sdk_android.runtime.AccountId
import io.novasama.substrate_sdk_android.runtime.definitions.types.composite.DictEnum
import kotlinx.coroutines.flow.Flow
class StableSwapSourceFactory(
private val hydraDxAssetIdConverter: HydraDxAssetIdConverter,
) : HydraDxSwapSource.Factory<StableSwapQuotingSource> {
override fun create(delegate: StableSwapQuotingSource): HydraDxSwapSource {
return StableSwapSource(
delegate = delegate,
hydraDxAssetIdConverter = hydraDxAssetIdConverter
)
}
override val identifier: String = StableSwapQuotingSourceFactory.ID
}
private class StableSwapSource(
private val delegate: StableSwapQuotingSource,
private val hydraDxAssetIdConverter: HydraDxAssetIdConverter,
) : HydraDxSwapSource, Identifiable by delegate {
private val chain = delegate.chain
override suspend fun sync() {
return delegate.sync()
}
override suspend fun availableSwapDirections(): Collection<HydraDxSourceEdge> {
return delegate.availableSwapDirections().map(::StableSwapEdge)
}
override suspend fun runSubscriptions(
userAccountId: AccountId,
subscriptionBuilder: SharedRequestsBuilder
): Flow<Unit> {
return delegate.runSubscriptions(userAccountId, subscriptionBuilder)
}
inner class StableSwapEdge(
private val delegate: StableSwapQuotingSource.Edge
) : HydraDxSourceEdge, QuotableEdge by delegate {
override val standaloneSwap = null
override suspend fun debugLabel(): String {
val poolAsset = hydraDxAssetIdConverter.toChainAssetOrThrow(chain, delegate.poolId)
return "StableSwap.${poolAsset.symbol}"
}
override fun routerPoolArgument(): DictEnum.Entry<*> {
return DictEnum.Entry("Stableswap", delegate.poolId)
}
}
}
@@ -0,0 +1,56 @@
package io.novafoundation.nova.feature_swap_impl.data.assetExchange.hydraDx.xyk
import io.novafoundation.nova.common.utils.Identifiable
import io.novafoundation.nova.core.updater.SharedRequestsBuilder
import io.novafoundation.nova.feature_swap_core.data.assetExchange.conversion.types.hydra.sources.xyk.XYKSwapQuotingSource
import io.novafoundation.nova.feature_swap_core.data.assetExchange.conversion.types.hydra.sources.xyk.XYKSwapQuotingSourceFactory
import io.novafoundation.nova.feature_swap_core_api.data.primitive.model.QuotableEdge
import io.novafoundation.nova.feature_swap_impl.data.assetExchange.hydraDx.HydraDxSourceEdge
import io.novafoundation.nova.feature_swap_impl.data.assetExchange.hydraDx.HydraDxSwapSource
import io.novasama.substrate_sdk_android.runtime.AccountId
import io.novasama.substrate_sdk_android.runtime.definitions.types.composite.DictEnum
import kotlinx.coroutines.flow.Flow
class XYKSwapSourceFactory : HydraDxSwapSource.Factory<XYKSwapQuotingSource> {
override val identifier: String = XYKSwapQuotingSourceFactory.ID
override fun create(delegate: XYKSwapQuotingSource): HydraDxSwapSource {
return XYKSwapSource(delegate)
}
}
private class XYKSwapSource(
private val delegate: XYKSwapQuotingSource,
) : HydraDxSwapSource, Identifiable by delegate {
override suspend fun sync() {
return delegate.sync()
}
override suspend fun availableSwapDirections(): Collection<HydraDxSourceEdge> {
return delegate.availableSwapDirections().map(::XYKSwapEdge)
}
override suspend fun runSubscriptions(
userAccountId: AccountId,
subscriptionBuilder: SharedRequestsBuilder
): Flow<Unit> {
return delegate.runSubscriptions(userAccountId, subscriptionBuilder)
}
inner class XYKSwapEdge(
private val delegate: XYKSwapQuotingSource.Edge
) : HydraDxSourceEdge, QuotableEdge by delegate {
override fun routerPoolArgument(): DictEnum.Entry<*> {
return DictEnum.Entry("XYK", null)
}
override val standaloneSwap = null
override suspend fun debugLabel(): String {
return "XYK"
}
}
}
@@ -0,0 +1,65 @@
package io.novafoundation.nova.feature_swap_impl.data.network.blockhain.updaters
import io.novafoundation.nova.common.utils.shareInBackground
import io.novafoundation.nova.core.updater.UpdateSystem
import io.novafoundation.nova.feature_account_api.domain.updaters.ChainUpdateScope
import io.novafoundation.nova.feature_swap_api.presentation.state.SwapSettingsState
import io.novafoundation.nova.feature_swap_api.presentation.state.SwapSettingsStateProvider
import io.novafoundation.nova.feature_wallet_api.domain.updater.AccountInfoUpdaterFactory
import io.novafoundation.nova.runtime.ethereum.StorageSharedRequestsBuilderFactory
import io.novafoundation.nova.runtime.ext.fullId
import io.novafoundation.nova.runtime.multiNetwork.ChainRegistry
import io.novafoundation.nova.runtime.multiNetwork.ChainWithAsset
import io.novafoundation.nova.runtime.multiNetwork.chain.model.Chain
import io.novafoundation.nova.runtime.network.updaters.ConstantSingleChainUpdateSystem
import io.novafoundation.nova.runtime.state.SelectedAssetOptionSharedState
import io.novafoundation.nova.runtime.state.SupportedAssetOption
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.distinctUntilChangedBy
import kotlinx.coroutines.flow.map
import kotlinx.coroutines.flow.mapNotNull
class SwapUpdateSystemFactory(
private val swapSettingsStateProvider: SwapSettingsStateProvider,
private val chainRegistry: ChainRegistry,
private val storageSharedRequestsBuilderFactory: StorageSharedRequestsBuilderFactory,
private val accountInfoUpdaterFactory: AccountInfoUpdaterFactory
) {
suspend fun create(chainFlow: Flow<Chain>, coroutineScope: CoroutineScope): UpdateSystem {
val swapSettingsState = swapSettingsStateProvider.getSwapSettingsState(coroutineScope)
val sharedStateAdapter = SwapSharedStateAdapter(swapSettingsState, chainRegistry, coroutineScope)
val updaters = listOf(
accountInfoUpdaterFactory.create(ChainUpdateScope(chainFlow), sharedStateAdapter)
)
return ConstantSingleChainUpdateSystem(
chainRegistry = chainRegistry,
singleAssetSharedState = sharedStateAdapter,
storageSharedRequestsBuilderFactory = storageSharedRequestsBuilderFactory,
updaters = updaters
)
}
}
/**
* Adapter wrapper to be able to use SwapSettingsState with UpdateSystem
*/
private class SwapSharedStateAdapter(
private val swapSettingsState: SwapSettingsState,
private val chainRegistry: ChainRegistry,
private val coroutineScope: CoroutineScope,
) : SelectedAssetOptionSharedState<Unit>, CoroutineScope by coroutineScope {
override val selectedOption: Flow<SelectedAssetOptionSharedState.SupportedAssetOption<Unit>> = swapSettingsState.selectedOption
.mapNotNull { it.assetIn }
.distinctUntilChangedBy { it.fullId }
.map { asset ->
val chain = chainRegistry.getChain(asset.chainId)
SupportedAssetOption(ChainWithAsset(chain, asset))
}
.shareInBackground()
}
@@ -0,0 +1,60 @@
package io.novafoundation.nova.feature_swap_impl.data.repository
import io.novafoundation.nova.core_db.dao.OperationDao
import io.novafoundation.nova.core_db.model.operation.SwapTypeLocal.AssetWithAmount
import io.novafoundation.nova.feature_account_api.data.extrinsic.ExtrinsicSubmission
import io.novafoundation.nova.feature_swap_api.domain.model.SwapFeeArgs
import io.novafoundation.nova.feature_swap_api.domain.model.SwapFee
import io.novafoundation.nova.feature_wallet_api.data.network.blockhain.types.Balance
import io.novafoundation.nova.runtime.ext.localId
import io.novafoundation.nova.runtime.multiNetwork.ChainRegistry
import io.novafoundation.nova.runtime.multiNetwork.chain.model.Chain
interface SwapTransactionHistoryRepository {
suspend fun insertPendingSwap(
chainAsset: Chain.Asset,
swapArgs: SwapFeeArgs,
fee: SwapFee,
txSubmission: ExtrinsicSubmission
)
}
class RealSwapTransactionHistoryRepository(
private val operationDao: OperationDao,
private val chainRegistry: ChainRegistry,
) : SwapTransactionHistoryRepository {
override suspend fun insertPendingSwap(
chainAsset: Chain.Asset,
swapArgs: SwapFeeArgs,
fee: SwapFee,
txSubmission: ExtrinsicSubmission
) {
// TODO swap history
// val chain = chainRegistry.getChain(chainAsset.chainId)
//
// val localOperation = with(swapArgs) {
// OperationLocal.manualSwap(
// hash = txSubmission.hash,
// originAddress = chain.addressOf(txSubmission.submissionOrigin.requestedOrigin),
// assetId = chainAsset.localId,
// // Insert fee regardless of who actually paid it
// fee = feeAsset.withAmountLocal(fee.networkFee.amount),
// amountIn = assetIn.withAmountLocal(swapLimit.expectedAmountIn),
// amountOut = assetOut.withAmountLocal(swapLimit.expectedAmountOut),
// status = OperationBaseLocal.Status.PENDING,
// source = OperationBaseLocal.Source.APP
// )
// }
//
// operationDao.insert(localOperation)
}
private fun Chain.Asset.withAmountLocal(amount: Balance): AssetWithAmount {
return AssetWithAmount(
assetId = localId,
amount = amount
)
}
}
@@ -0,0 +1,68 @@
package io.novafoundation.nova.feature_swap_impl.di
import dagger.BindsInstance
import dagger.Component
import io.novafoundation.nova.common.di.CommonApi
import io.novafoundation.nova.common.di.scope.FeatureScope
import io.novafoundation.nova.core_db.di.DbApi
import io.novafoundation.nova.feature_account_api.di.AccountFeatureApi
import io.novafoundation.nova.feature_buy_api.di.BuyFeatureApi
import io.novafoundation.nova.feature_swap_api.di.SwapFeatureApi
import io.novafoundation.nova.feature_swap_core_api.di.SwapCoreApi
import io.novafoundation.nova.feature_swap_impl.presentation.SwapRouter
import io.novafoundation.nova.feature_swap_impl.presentation.confirmation.di.SwapConfirmationComponent
import io.novafoundation.nova.feature_swap_impl.presentation.execution.di.SwapExecutionComponent
import io.novafoundation.nova.feature_swap_impl.presentation.fee.di.SwapFeeComponent
import io.novafoundation.nova.feature_swap_impl.presentation.main.di.SwapMainSettingsComponent
import io.novafoundation.nova.feature_swap_impl.presentation.options.di.SwapOptionsComponent
import io.novafoundation.nova.feature_swap_impl.presentation.route.di.SwapRouteComponent
import io.novafoundation.nova.feature_wallet_api.di.WalletFeatureApi
import io.novafoundation.nova.feature_xcm_api.di.XcmFeatureApi
import io.novafoundation.nova.runtime.di.RuntimeApi
@Component(
dependencies = [
SwapFeatureDependencies::class,
],
modules = [
SwapFeatureModule::class,
]
)
@FeatureScope
interface SwapFeatureComponent : SwapFeatureApi {
fun swapMainSettings(): SwapMainSettingsComponent.Factory
fun swapConfirmation(): SwapConfirmationComponent.Factory
fun swapOptions(): SwapOptionsComponent.Factory
fun swapRoute(): SwapRouteComponent.Factory
fun swapFee(): SwapFeeComponent.Factory
fun swapExecution(): SwapExecutionComponent.Factory
@Component.Factory
interface Factory {
fun create(
deps: SwapFeatureDependencies,
@BindsInstance router: SwapRouter,
): SwapFeatureComponent
}
@Component(
dependencies = [
CommonApi::class,
RuntimeApi::class,
WalletFeatureApi::class,
AccountFeatureApi::class,
BuyFeatureApi::class,
DbApi::class,
SwapCoreApi::class,
XcmFeatureApi::class,
]
)
interface SwapFeatureDependenciesComponent : SwapFeatureDependencies
}
@@ -0,0 +1,173 @@
package io.novafoundation.nova.feature_swap_impl.di
import coil.ImageLoader
import com.google.gson.Gson
import io.novafoundation.nova.common.address.AddressIconGenerator
import io.novafoundation.nova.common.data.memory.ComputationalCache
import io.novafoundation.nova.common.data.network.NetworkApiCreator
import io.novafoundation.nova.common.data.storage.Preferences
import io.novafoundation.nova.common.mixin.actionAwaitable.ActionAwaitableMixin
import io.novafoundation.nova.common.mixin.hints.ResourcesHintsMixinFactory
import io.novafoundation.nova.common.presentation.AssetIconProvider
import io.novafoundation.nova.common.resources.ResourceManager
import io.novafoundation.nova.common.validation.ValidationExecutor
import io.novafoundation.nova.common.view.bottomSheet.description.DescriptionBottomSheetLauncher
import io.novafoundation.nova.common.view.input.chooser.ListChooserMixin
import io.novafoundation.nova.core.storage.StorageCache
import io.novafoundation.nova.core_db.dao.OperationDao
import io.novafoundation.nova.feature_account_api.data.extrinsic.ExtrinsicService
import io.novafoundation.nova.feature_account_api.data.fee.FeePaymentProviderRegistry
import io.novafoundation.nova.feature_account_api.data.fee.capability.CustomFeeCapabilityFacade
import io.novafoundation.nova.feature_account_api.data.fee.types.hydra.HydrationFeeInjector
import io.novafoundation.nova.feature_account_api.data.repository.OnChainIdentityRepository
import io.novafoundation.nova.feature_account_api.data.signer.SignerProvider
import io.novafoundation.nova.feature_account_api.domain.interfaces.AccountRepository
import io.novafoundation.nova.feature_account_api.domain.interfaces.SelectedAccountUseCase
import io.novafoundation.nova.feature_account_api.presenatation.account.wallet.WalletUiUseCase
import io.novafoundation.nova.feature_account_api.presenatation.actions.ExternalActions
import io.novafoundation.nova.feature_account_api.presenatation.mixin.identity.IdentityMixin
import io.novafoundation.nova.feature_account_api.presenatation.navigation.ExtrinsicNavigationWrapper
import io.novafoundation.nova.feature_buy_api.presentation.mixin.TradeMixin
import io.novafoundation.nova.feature_buy_api.presentation.trade.TradeTokenRegistry
import io.novafoundation.nova.feature_swap_core_api.data.network.HydraDxAssetIdConverter
import io.novafoundation.nova.feature_swap_core_api.data.paths.PathQuoter
import io.novafoundation.nova.feature_swap_core_api.data.types.hydra.HydraDxQuoting
import io.novafoundation.nova.feature_swap_core_api.data.types.hydra.HydrationAcceptedFeeCurrenciesFetcher
import io.novafoundation.nova.feature_swap_core_api.data.types.hydra.HydrationPriceConversionFallback
import io.novafoundation.nova.feature_wallet_api.data.network.blockhain.assets.AssetSourceRegistry
import io.novafoundation.nova.feature_wallet_api.data.network.crosschain.CrossChainTransfersRepository
import io.novafoundation.nova.feature_wallet_api.data.repository.AccountInfoRepository
import io.novafoundation.nova.feature_wallet_api.domain.ArbitraryAssetUseCase
import io.novafoundation.nova.feature_wallet_api.domain.interfaces.CrossChainTransfersUseCase
import io.novafoundation.nova.feature_wallet_api.domain.interfaces.TokenRepository
import io.novafoundation.nova.feature_wallet_api.domain.interfaces.WalletRepository
import io.novafoundation.nova.feature_wallet_api.domain.validation.context.AssetsValidationContext
import io.novafoundation.nova.feature_wallet_api.presentation.common.fieldValidator.EnoughAmountValidatorFactory
import io.novafoundation.nova.feature_wallet_api.presentation.formatters.amount.AmountFormatter
import io.novafoundation.nova.feature_wallet_api.presentation.mixin.amountChooser.AmountChooserMixin
import io.novafoundation.nova.feature_wallet_api.presentation.mixin.fee.v2.FeeLoaderMixinV2
import io.novafoundation.nova.feature_wallet_api.presentation.mixin.getAsset.GetAssetOptionsMixin
import io.novafoundation.nova.feature_wallet_api.presentation.mixin.maxAction.MaxActionProviderFactory
import io.novafoundation.nova.feature_xcm_api.converter.MultiLocationConverterFactory
import io.novafoundation.nova.feature_xcm_api.versions.detector.XcmVersionDetector
import io.novafoundation.nova.runtime.call.MultiChainRuntimeCallsApi
import io.novafoundation.nova.runtime.di.REMOTE_STORAGE_SOURCE
import io.novafoundation.nova.runtime.ethereum.StorageSharedRequestsBuilderFactory
import io.novafoundation.nova.runtime.multiNetwork.ChainRegistry
import io.novafoundation.nova.runtime.repository.ChainStateRepository
import io.novafoundation.nova.runtime.storage.source.StorageDataSource
import javax.inject.Named
interface SwapFeatureDependencies {
val amountFormatter: AmountFormatter
val validationExecutor: ValidationExecutor
val preferences: Preferences
val walletRepository: WalletRepository
val chainRegistry: ChainRegistry
val imageLoader: ImageLoader
val addressIconGenerator: AddressIconGenerator
val resourceManager: ResourceManager
val actionAwaitableMixinFactory: ActionAwaitableMixin.Factory
val tokenRepository: TokenRepository
val accountRepository: AccountRepository
val selectedAccountUseCase: SelectedAccountUseCase
val storageCache: StorageCache
val externalAccountActions: ExternalActions.Presentation
val amountMixinFactory: AmountChooserMixin.Factory
val extrinsicServiceFactory: ExtrinsicService.Factory
val resourceHintsMixinFactory: ResourcesHintsMixinFactory
val walletUiUseCase: WalletUiUseCase
val computationalCache: ComputationalCache
val networkApiCreator: NetworkApiCreator
@Named(REMOTE_STORAGE_SOURCE)
fun remoteStorageDataSource(): StorageDataSource
val onChainIdentityRepository: OnChainIdentityRepository
val listChooserMixinFactory: ListChooserMixin.Factory
val identityMixinFactory: IdentityMixin.Factory
val storageStorageSharedRequestsBuilderFactory: StorageSharedRequestsBuilderFactory
val hydraDxAssetIdConverter: HydraDxAssetIdConverter
val hydraDxQuotingFactory: HydraDxQuoting.Factory
val hydrationPriceConversionFallback: HydrationPriceConversionFallback
val runtimeCallsApi: MultiChainRuntimeCallsApi
val assetUseCase: ArbitraryAssetUseCase
val assetSourceRegistry: AssetSourceRegistry
val chainStateRepository: ChainStateRepository
val descriptionBottomSheetLauncher: DescriptionBottomSheetLauncher
val crossChainTransfersRepository: CrossChainTransfersRepository
val buyTokenRegistry: TradeTokenRegistry
val tradeMixinFactory: TradeMixin.Factory
val crossChainTransfersUseCase: CrossChainTransfersUseCase
val operationDao: OperationDao
val multiLocationConverterFactory: MultiLocationConverterFactory
val gson: Gson
val customFeeCapabilityFacade: CustomFeeCapabilityFacade
val quoterFactory: PathQuoter.Factory
val hydrationFeeInjector: HydrationFeeInjector
val defaultFeePaymentRegistry: FeePaymentProviderRegistry
val feeLoaderMixinV2Factory: FeeLoaderMixinV2.Factory
val assetIconProvider: AssetIconProvider
val assetsValidationContextFactory: AssetsValidationContext.Factory
val xcmVersionDetector: XcmVersionDetector
val extrinsicNavigationWrapper: ExtrinsicNavigationWrapper
val signerProvider: SignerProvider
val accountInfoRepository: AccountInfoRepository
val enoughAmountValidatorFactory: EnoughAmountValidatorFactory
val getAssetOptionsMixinFactory: GetAssetOptionsMixin.Factory
val hydrationAcceptedFeeCurrenciesFetcher: HydrationAcceptedFeeCurrenciesFetcher
fun maxActionProviderFactory(): MaxActionProviderFactory
}
@@ -0,0 +1,37 @@
package io.novafoundation.nova.feature_swap_impl.di
import io.novafoundation.nova.common.di.FeatureApiHolder
import io.novafoundation.nova.common.di.FeatureContainer
import io.novafoundation.nova.common.di.scope.ApplicationScope
import io.novafoundation.nova.core_db.di.DbApi
import io.novafoundation.nova.feature_account_api.di.AccountFeatureApi
import io.novafoundation.nova.feature_buy_api.di.BuyFeatureApi
import io.novafoundation.nova.feature_swap_core_api.di.SwapCoreApi
import io.novafoundation.nova.feature_swap_impl.presentation.SwapRouter
import io.novafoundation.nova.feature_wallet_api.di.WalletFeatureApi
import io.novafoundation.nova.feature_xcm_api.di.XcmFeatureApi
import io.novafoundation.nova.runtime.di.RuntimeApi
import javax.inject.Inject
@ApplicationScope
class SwapFeatureHolder @Inject constructor(
featureContainer: FeatureContainer,
private val router: SwapRouter,
) : FeatureApiHolder(featureContainer) {
override fun initializeDependencies(): Any {
val accountFeatureDependencies = DaggerSwapFeatureComponent_SwapFeatureDependenciesComponent.builder()
.commonApi(commonApi())
.dbApi(getFeature(DbApi::class.java))
.runtimeApi(getFeature(RuntimeApi::class.java))
.swapCoreApi(getFeature(SwapCoreApi::class.java))
.walletFeatureApi(getFeature(WalletFeatureApi::class.java))
.accountFeatureApi(getFeature(AccountFeatureApi::class.java))
.buyFeatureApi(getFeature(BuyFeatureApi::class.java))
.xcmFeatureApi(getFeature(XcmFeatureApi::class.java))
.build()
return DaggerSwapFeatureComponent.factory()
.create(accountFeatureDependencies, router)
}
}
@@ -0,0 +1,248 @@
package io.novafoundation.nova.feature_swap_impl.di
import dagger.Module
import dagger.Provides
import io.novafoundation.nova.common.data.memory.ComputationalCache
import io.novafoundation.nova.common.di.scope.FeatureScope
import io.novafoundation.nova.common.presentation.AssetIconProvider
import io.novafoundation.nova.common.resources.ResourceManager
import io.novafoundation.nova.common.utils.Fraction.Companion.percents
import io.novafoundation.nova.core.storage.StorageCache
import io.novafoundation.nova.core_db.dao.OperationDao
import io.novafoundation.nova.feature_account_api.data.extrinsic.ExtrinsicService
import io.novafoundation.nova.feature_account_api.data.fee.FeePaymentProviderRegistry
import io.novafoundation.nova.feature_account_api.data.signer.SignerProvider
import io.novafoundation.nova.feature_account_api.domain.interfaces.AccountRepository
import io.novafoundation.nova.feature_swap_api.domain.interactor.SwapAvailabilityInteractor
import io.novafoundation.nova.feature_swap_api.domain.swap.SwapService
import io.novafoundation.nova.feature_swap_api.presentation.formatters.SwapRateFormatter
import io.novafoundation.nova.feature_swap_api.presentation.navigation.SwapFlowScopeAggregator
import io.novafoundation.nova.feature_swap_api.presentation.state.SwapSettingsStateProvider
import io.novafoundation.nova.feature_swap_core_api.data.paths.PathQuoter
import io.novafoundation.nova.feature_swap_impl.data.assetExchange.assetConversion.AssetConversionExchangeFactory
import io.novafoundation.nova.feature_swap_impl.data.assetExchange.crossChain.CrossChainTransferAssetExchangeFactory
import io.novafoundation.nova.feature_swap_impl.data.assetExchange.hydraDx.HydraDxExchangeFactory
import io.novafoundation.nova.feature_swap_impl.data.network.blockhain.updaters.SwapUpdateSystemFactory
import io.novafoundation.nova.feature_swap_impl.data.repository.RealSwapTransactionHistoryRepository
import io.novafoundation.nova.feature_swap_impl.data.repository.SwapTransactionHistoryRepository
import io.novafoundation.nova.feature_swap_impl.di.exchanges.AssetConversionExchangeModule
import io.novafoundation.nova.feature_swap_impl.di.exchanges.CrossChainTransferExchangeModule
import io.novafoundation.nova.feature_swap_impl.di.exchanges.HydraDxExchangeModule
import io.novafoundation.nova.feature_swap_impl.domain.AssetInAdditionalSwapDeductionUseCase
import io.novafoundation.nova.feature_swap_impl.domain.RealAssetInAdditionalSwapDeductionUseCase
import io.novafoundation.nova.feature_swap_impl.domain.interactor.RealSwapAvailabilityInteractor
import io.novafoundation.nova.feature_swap_impl.domain.interactor.SwapInteractor
import io.novafoundation.nova.feature_swap_impl.domain.swap.PriceImpactThresholds
import io.novafoundation.nova.feature_swap_impl.domain.swap.RealSwapService
import io.novafoundation.nova.feature_swap_impl.domain.validation.validations.CanReceiveAssetOutValidationFactory
import io.novafoundation.nova.feature_swap_impl.presentation.common.PriceImpactFormatter
import io.novafoundation.nova.feature_swap_impl.presentation.common.RealPriceImpactFormatter
import io.novafoundation.nova.feature_swap_impl.presentation.common.RealSwapRateFormatter
import io.novafoundation.nova.feature_swap_impl.presentation.common.SlippageAlertMixinFactory
import io.novafoundation.nova.feature_swap_impl.presentation.common.details.RealSwapConfirmationDetailsFormatter
import io.novafoundation.nova.feature_swap_impl.presentation.common.details.SwapConfirmationDetailsFormatter
import io.novafoundation.nova.feature_swap_impl.presentation.common.navigation.RealSwapFlowScopeAggregator
import io.novafoundation.nova.feature_swap_impl.presentation.common.route.RealSwapRouteFormatter
import io.novafoundation.nova.feature_swap_impl.presentation.common.route.SwapRouteFormatter
import io.novafoundation.nova.feature_swap_impl.presentation.common.state.RealSwapSettingsStateProvider
import io.novafoundation.nova.feature_swap_impl.presentation.common.state.RealSwapStateStoreProvider
import io.novafoundation.nova.feature_swap_impl.presentation.common.state.SwapStateStoreProvider
import io.novafoundation.nova.feature_wallet_api.data.network.blockhain.assets.AssetSourceRegistry
import io.novafoundation.nova.feature_wallet_api.domain.interfaces.TokenRepository
import io.novafoundation.nova.feature_wallet_api.domain.updater.AccountInfoUpdaterFactory
import io.novafoundation.nova.feature_wallet_api.domain.validation.context.AssetsValidationContext
import io.novafoundation.nova.feature_wallet_api.presentation.formatters.amount.AmountFormatter
import io.novafoundation.nova.runtime.ethereum.StorageSharedRequestsBuilderFactory
import io.novafoundation.nova.runtime.multiNetwork.ChainRegistry
import io.novafoundation.nova.runtime.repository.ChainStateRepository
@Module(includes = [HydraDxExchangeModule::class, AssetConversionExchangeModule::class, CrossChainTransferExchangeModule::class])
class SwapFeatureModule {
@FeatureScope
@Provides
fun provideSwapService(
assetConversionFactory: AssetConversionExchangeFactory,
hydraDxExchangeFactory: HydraDxExchangeFactory,
crossChainTransferAssetExchangeFactory: CrossChainTransferAssetExchangeFactory,
computationalCache: ComputationalCache,
chainRegistry: ChainRegistry,
quoterFactory: PathQuoter.Factory,
extrinsicServiceFactory: ExtrinsicService.Factory,
defaultFeePaymentRegistry: FeePaymentProviderRegistry,
tokenRepository: TokenRepository,
accountRepository: AccountRepository,
assetSourceRegistry: AssetSourceRegistry,
chainStateRepository: ChainStateRepository,
signerProvider: SignerProvider
): SwapService {
return RealSwapService(
assetConversionFactory = assetConversionFactory,
hydraDxExchangeFactory = hydraDxExchangeFactory,
crossChainTransferFactory = crossChainTransferAssetExchangeFactory,
computationalCache = computationalCache,
chainRegistry = chainRegistry,
quoterFactory = quoterFactory,
extrinsicServiceFactory = extrinsicServiceFactory,
defaultFeePaymentProviderRegistry = defaultFeePaymentRegistry,
tokenRepository = tokenRepository,
assetSourceRegistry = assetSourceRegistry,
accountRepository = accountRepository,
chainStateRepository = chainStateRepository,
signerProvider = signerProvider
)
}
@Provides
@FeatureScope
fun provideSwapAvailabilityInteractor(chainRegistry: ChainRegistry, swapService: SwapService): SwapAvailabilityInteractor {
return RealSwapAvailabilityInteractor(chainRegistry, swapService)
}
@Provides
@FeatureScope
fun provideSwapTransactionHistoryRepository(
operationDao: OperationDao,
chainRegistry: ChainRegistry,
): SwapTransactionHistoryRepository {
return RealSwapTransactionHistoryRepository(operationDao, chainRegistry)
}
@Provides
@FeatureScope
fun provideSwapInteractor(
priceImpactThresholds: PriceImpactThresholds,
swapService: SwapService,
tokenRepository: TokenRepository,
swapUpdateSystemFactory: SwapUpdateSystemFactory,
assetsValidationContextFactory: AssetsValidationContext.Factory,
canReceiveAssetOutValidationFactory: CanReceiveAssetOutValidationFactory,
): SwapInteractor {
return SwapInteractor(
priceImpactThresholds = priceImpactThresholds,
swapService = swapService,
canReceiveAssetOutValidationFactory = canReceiveAssetOutValidationFactory,
swapUpdateSystemFactory = swapUpdateSystemFactory,
assetsValidationContextFactory = assetsValidationContextFactory,
tokenRepository = tokenRepository
)
}
@Provides
@FeatureScope
fun providePriceImpactThresholds() = PriceImpactThresholds(
lowPriceImpact = 1.percents,
mediumPriceImpact = 5.percents,
highPriceImpact = 15.percents
)
@Provides
@FeatureScope
fun providePriceImpactFormatter(
priceImpactThresholds: PriceImpactThresholds,
resourceManager: ResourceManager
): PriceImpactFormatter {
return RealPriceImpactFormatter(priceImpactThresholds, resourceManager)
}
@Provides
@FeatureScope
fun provideSwapRateFormatter(): SwapRateFormatter {
return RealSwapRateFormatter()
}
@Provides
@FeatureScope
fun provideSlippageAlertMixinFactory(resourceManager: ResourceManager): SlippageAlertMixinFactory {
return SlippageAlertMixinFactory(resourceManager)
}
@Provides
@FeatureScope
fun provideSwapSettingsStateProvider(
computationalCache: ComputationalCache,
): SwapSettingsStateProvider {
return RealSwapSettingsStateProvider(computationalCache)
}
@Provides
@FeatureScope
fun provideAccountInfoUpdaterFactory(
storageCache: StorageCache,
accountRepository: AccountRepository,
chainRegistry: ChainRegistry
): AccountInfoUpdaterFactory {
return AccountInfoUpdaterFactory(
storageCache,
accountRepository,
chainRegistry
)
}
@Provides
@FeatureScope
fun provideSwapUpdateSystemFactory(
swapSettingsStateProvider: SwapSettingsStateProvider,
chainRegistry: ChainRegistry,
storageSharedRequestsBuilderFactory: StorageSharedRequestsBuilderFactory,
accountInfoUpdaterFactory: AccountInfoUpdaterFactory
): SwapUpdateSystemFactory {
return SwapUpdateSystemFactory(
swapSettingsStateProvider = swapSettingsStateProvider,
chainRegistry = chainRegistry,
storageSharedRequestsBuilderFactory = storageSharedRequestsBuilderFactory,
accountInfoUpdaterFactory = accountInfoUpdaterFactory
)
}
@Provides
@FeatureScope
fun provideSwapQuoteStoreProvider(computationalCache: ComputationalCache): SwapStateStoreProvider {
return RealSwapStateStoreProvider(computationalCache)
}
@Provides
@FeatureScope
fun provideSwapRouteFormatter(chainRegistry: ChainRegistry): SwapRouteFormatter {
return RealSwapRouteFormatter(chainRegistry)
}
@Provides
@FeatureScope
fun provideConfirmationDetailsFormatter(
chainRegistry: ChainRegistry,
assetIconProvider: AssetIconProvider,
tokenRepository: TokenRepository,
swapRouteFormatter: SwapRouteFormatter,
swapRateFormatter: SwapRateFormatter,
priceImpactFormatter: PriceImpactFormatter,
resourceManager: ResourceManager,
amountFormatter: AmountFormatter
): SwapConfirmationDetailsFormatter {
return RealSwapConfirmationDetailsFormatter(
chainRegistry = chainRegistry,
assetIconProvider = assetIconProvider,
tokenRepository = tokenRepository,
swapRouteFormatter = swapRouteFormatter,
swapRateFormatter = swapRateFormatter,
priceImpactFormatter = priceImpactFormatter,
resourceManager = resourceManager,
amountFormatter = amountFormatter
)
}
@Provides
@FeatureScope
fun provideAssetInAdditionalSwapDeductionUseCase(
assetSourceRegistry: AssetSourceRegistry,
chainRegistry: ChainRegistry
): AssetInAdditionalSwapDeductionUseCase {
return RealAssetInAdditionalSwapDeductionUseCase(assetSourceRegistry, chainRegistry)
}
@Provides
@FeatureScope
fun provideSwapFlowScopeAggregator(): SwapFlowScopeAggregator {
return RealSwapFlowScopeAggregator()
}
}
@@ -0,0 +1,38 @@
package io.novafoundation.nova.feature_swap_impl.di.exchanges
import dagger.Module
import dagger.Provides
import io.novafoundation.nova.common.di.scope.FeatureScope
import io.novafoundation.nova.feature_swap_impl.data.assetExchange.assetConversion.AssetConversionExchangeFactory
import io.novafoundation.nova.feature_swap_impl.domain.AssetInAdditionalSwapDeductionUseCase
import io.novafoundation.nova.feature_xcm_api.converter.MultiLocationConverterFactory
import io.novafoundation.nova.feature_xcm_api.versions.detector.XcmVersionDetector
import io.novafoundation.nova.runtime.call.MultiChainRuntimeCallsApi
import io.novafoundation.nova.runtime.di.REMOTE_STORAGE_SOURCE
import io.novafoundation.nova.runtime.repository.ChainStateRepository
import io.novafoundation.nova.runtime.storage.source.StorageDataSource
import javax.inject.Named
@Module
class AssetConversionExchangeModule {
@Provides
@FeatureScope
fun provideAssetConversionExchangeFactory(
@Named(REMOTE_STORAGE_SOURCE) remoteStorageSource: StorageDataSource,
runtimeCallsApi: MultiChainRuntimeCallsApi,
multiLocationConverterFactory: MultiLocationConverterFactory,
chainStateRepository: ChainStateRepository,
deductionUseCase: AssetInAdditionalSwapDeductionUseCase,
xcmVersionDetector: XcmVersionDetector
): AssetConversionExchangeFactory {
return AssetConversionExchangeFactory(
chainStateRepository = chainStateRepository,
remoteStorageSource = remoteStorageSource,
runtimeCallsApi = runtimeCallsApi,
multiLocationConverterFactory = multiLocationConverterFactory,
deductionUseCase = deductionUseCase,
xcmVersionDetector = xcmVersionDetector
)
}
}
@@ -0,0 +1,27 @@
package io.novafoundation.nova.feature_swap_impl.di.exchanges
import dagger.Module
import dagger.Provides
import io.novafoundation.nova.common.di.scope.FeatureScope
import io.novafoundation.nova.feature_account_api.domain.interfaces.AccountRepository
import io.novafoundation.nova.feature_swap_impl.data.assetExchange.crossChain.CrossChainTransferAssetExchangeFactory
import io.novafoundation.nova.feature_wallet_api.domain.interfaces.CrossChainTransfersUseCase
import io.novafoundation.nova.runtime.multiNetwork.ChainRegistry
@Module
class CrossChainTransferExchangeModule {
@Provides
@FeatureScope
fun provideAssetConversionExchangeFactory(
crossChainTransfersUseCase: CrossChainTransfersUseCase,
chainRegistry: ChainRegistry,
accountRepository: AccountRepository
): CrossChainTransferAssetExchangeFactory {
return CrossChainTransferAssetExchangeFactory(
crossChainTransfersUseCase = crossChainTransfersUseCase,
chainRegistry = chainRegistry,
accountRepository = accountRepository
)
}
}
@@ -0,0 +1,91 @@
package io.novafoundation.nova.feature_swap_impl.di.exchanges
import dagger.Module
import dagger.Provides
import dagger.multibindings.IntoSet
import io.novafoundation.nova.common.di.scope.FeatureScope
import io.novafoundation.nova.feature_account_api.data.fee.types.hydra.HydrationFeeInjector
import io.novafoundation.nova.feature_swap_core_api.data.network.HydraDxAssetIdConverter
import io.novafoundation.nova.feature_swap_core_api.data.types.hydra.HydraDxQuoting
import io.novafoundation.nova.feature_swap_core_api.data.types.hydra.HydrationAcceptedFeeCurrenciesFetcher
import io.novafoundation.nova.feature_swap_core_api.data.types.hydra.HydrationPriceConversionFallback
import io.novafoundation.nova.feature_swap_impl.data.assetExchange.hydraDx.HydraDxExchangeFactory
import io.novafoundation.nova.feature_swap_impl.data.assetExchange.hydraDx.HydraDxSwapSource
import io.novafoundation.nova.feature_swap_impl.data.assetExchange.hydraDx.aave.AaveSwapSourceFactory
import io.novafoundation.nova.feature_swap_impl.data.assetExchange.hydraDx.omnipool.OmniPoolSwapSourceFactory
import io.novafoundation.nova.feature_swap_impl.data.assetExchange.hydraDx.referrals.HydraDxNovaReferral
import io.novafoundation.nova.feature_swap_impl.data.assetExchange.hydraDx.referrals.RealHydraDxNovaReferral
import io.novafoundation.nova.feature_swap_impl.data.assetExchange.hydraDx.stableswap.StableSwapSourceFactory
import io.novafoundation.nova.feature_swap_impl.data.assetExchange.hydraDx.xyk.XYKSwapSourceFactory
import io.novafoundation.nova.feature_swap_impl.domain.AssetInAdditionalSwapDeductionUseCase
import io.novafoundation.nova.runtime.di.REMOTE_STORAGE_SOURCE
import io.novafoundation.nova.runtime.ethereum.StorageSharedRequestsBuilderFactory
import io.novafoundation.nova.runtime.repository.ChainStateRepository
import io.novafoundation.nova.runtime.storage.source.StorageDataSource
import javax.inject.Named
@Module
class HydraDxExchangeModule {
@Provides
@FeatureScope
fun provideHydraDxNovaReferral(): HydraDxNovaReferral {
return RealHydraDxNovaReferral()
}
@Provides
@IntoSet
fun provideOmniPoolSourceFactory(): HydraDxSwapSource.Factory<*> {
return OmniPoolSwapSourceFactory()
}
@Provides
@IntoSet
fun provideAaveSourceFactory(real: AaveSwapSourceFactory): HydraDxSwapSource.Factory<*> {
return real
}
@Provides
@IntoSet
fun provideStableSwapSourceFactory(
hydraDxAssetIdConverter: HydraDxAssetIdConverter,
): HydraDxSwapSource.Factory<*> {
return StableSwapSourceFactory(hydraDxAssetIdConverter)
}
@Provides
@IntoSet
fun provideXykSwapSourceFactory(): HydraDxSwapSource.Factory<*> {
return XYKSwapSourceFactory()
}
@Provides
@FeatureScope
fun provideHydraDxExchangeFactory(
@Named(REMOTE_STORAGE_SOURCE) remoteStorageSource: StorageDataSource,
sharedRequestsBuilderFactory: StorageSharedRequestsBuilderFactory,
hydraDxAssetIdConverter: HydraDxAssetIdConverter,
hydraDxNovaReferral: HydraDxNovaReferral,
swapSourceFactories: Set<@JvmSuppressWildcards HydraDxSwapSource.Factory<*>>,
quotingFactory: HydraDxQuoting.Factory,
hydrationFeeInjector: HydrationFeeInjector,
chainStateRepository: ChainStateRepository,
swapDeductionUseCase: AssetInAdditionalSwapDeductionUseCase,
hydrationPriceConversionFallback: HydrationPriceConversionFallback,
hydrationAcceptedFeeCurrenciesFetcher: HydrationAcceptedFeeCurrenciesFetcher,
): HydraDxExchangeFactory {
return HydraDxExchangeFactory(
remoteStorageSource = remoteStorageSource,
sharedRequestsBuilderFactory = sharedRequestsBuilderFactory,
hydraDxAssetIdConverter = hydraDxAssetIdConverter,
hydraDxNovaReferral = hydraDxNovaReferral,
swapSourceFactories = swapSourceFactories,
quotingFactory = quotingFactory,
hydrationFeeInjector = hydrationFeeInjector,
chainStateRepository = chainStateRepository,
swapDeductionUseCase = swapDeductionUseCase,
hydrationPriceConversionFallback = hydrationPriceConversionFallback,
hydrationAcceptedFeeCurrenciesFetcher = hydrationAcceptedFeeCurrenciesFetcher
)
}
}
@@ -0,0 +1,38 @@
package io.novafoundation.nova.feature_swap_impl.domain
import io.novafoundation.nova.feature_wallet_api.data.network.blockhain.assets.AssetSourceRegistry
import io.novafoundation.nova.feature_wallet_api.data.network.blockhain.assets.existentialDepositInPlanks
import io.novafoundation.nova.feature_wallet_api.data.network.blockhain.assets.isSelfSufficientAsset
import io.novafoundation.nova.feature_wallet_api.data.network.blockhain.types.Balance
import io.novafoundation.nova.runtime.multiNetwork.ChainRegistry
import io.novafoundation.nova.runtime.multiNetwork.chain.model.Chain
interface AssetInAdditionalSwapDeductionUseCase {
suspend fun invoke(assetIn: Chain.Asset, assetOut: Chain.Asset): Balance
}
class RealAssetInAdditionalSwapDeductionUseCase(
private val assetSourceRegistry: AssetSourceRegistry,
private val chainRegistry: ChainRegistry
) : AssetInAdditionalSwapDeductionUseCase {
override suspend fun invoke(assetIn: Chain.Asset, assetOut: Chain.Asset): Balance {
val assetInBalanceCanDropBelowEd = assetSourceRegistry.sourceFor(assetIn)
.transfers
.totalCanDropBelowMinimumBalance(assetIn)
val sameChain = assetIn.chainId == assetOut.chainId
val assetOutCanProvideSufficiency = sameChain && assetSourceRegistry.isSelfSufficientAsset(assetOut)
val canDustAssetIn = assetInBalanceCanDropBelowEd || assetOutCanProvideSufficiency
val shouldKeepEdForAssetIn = !canDustAssetIn
return if (shouldKeepEdForAssetIn) {
assetSourceRegistry.existentialDepositInPlanks(assetIn)
} else {
Balance.ZERO
}
}
}
@@ -0,0 +1,33 @@
package io.novafoundation.nova.feature_swap_impl.domain.interactor
import io.novafoundation.nova.feature_swap_api.domain.interactor.SwapAvailabilityInteractor
import io.novafoundation.nova.feature_swap_api.domain.swap.SwapService
import io.novafoundation.nova.runtime.ext.isSwapSupported
import io.novafoundation.nova.runtime.multiNetwork.ChainRegistry
import io.novafoundation.nova.runtime.multiNetwork.chain.model.Chain
import io.novafoundation.nova.runtime.multiNetwork.enabledChainsFlow
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.map
class RealSwapAvailabilityInteractor(
private val chainRegistry: ChainRegistry,
private val swapService: SwapService
) : SwapAvailabilityInteractor {
override suspend fun sync(coroutineScope: CoroutineScope) {
swapService.sync(coroutineScope)
}
override suspend fun warmUpCommonlyUsedChains(computationScope: CoroutineScope) {
swapService.warmUpCommonChains(computationScope)
}
override fun anySwapAvailableFlow(): Flow<Boolean> {
return chainRegistry.enabledChainsFlow().map { it.any(Chain::isSwapSupported) }
}
override suspend fun swapAvailableFlow(asset: Chain.Asset, coroutineScope: CoroutineScope): Flow<Boolean> {
return swapService.hasAvailableSwapDirections(asset, coroutineScope)
}
}
@@ -0,0 +1,180 @@
package io.novafoundation.nova.feature_swap_impl.domain.interactor
import io.novafoundation.nova.common.validation.ValidationSystem
import io.novafoundation.nova.core.updater.UpdateSystem
import io.novafoundation.nova.feature_account_api.domain.model.MetaAccount
import io.novafoundation.nova.feature_swap_api.domain.model.ReQuoteTrigger
import io.novafoundation.nova.feature_swap_api.domain.model.SlippageConfig
import io.novafoundation.nova.feature_swap_api.domain.model.SwapFee
import io.novafoundation.nova.feature_swap_api.domain.model.SwapFeeArgs
import io.novafoundation.nova.feature_swap_api.domain.model.SwapProgress
import io.novafoundation.nova.feature_swap_api.domain.model.SwapQuote
import io.novafoundation.nova.feature_swap_api.domain.model.SwapQuoteArgs
import io.novafoundation.nova.feature_swap_api.domain.model.SwapSubmissionResult
import io.novafoundation.nova.feature_swap_api.domain.model.allBasicFees
import io.novafoundation.nova.feature_swap_api.domain.swap.SwapService
import io.novafoundation.nova.feature_swap_impl.data.network.blockhain.updaters.SwapUpdateSystemFactory
import io.novafoundation.nova.feature_swap_impl.domain.swap.PriceImpactThresholds
import io.novafoundation.nova.feature_swap_impl.domain.validation.SwapValidationSystem
import io.novafoundation.nova.feature_swap_impl.domain.validation.availableSlippage
import io.novafoundation.nova.feature_swap_impl.domain.validation.canPayAllFees
import io.novafoundation.nova.feature_swap_impl.domain.validation.enoughAssetInToPayForSwap
import io.novafoundation.nova.feature_swap_impl.domain.validation.enoughAssetInToPayForSwapAndFee
import io.novafoundation.nova.feature_swap_impl.domain.validation.enoughLiquidity
import io.novafoundation.nova.feature_swap_impl.domain.validation.positiveAmountIn
import io.novafoundation.nova.feature_swap_impl.domain.validation.positiveAmountOut
import io.novafoundation.nova.feature_swap_impl.domain.validation.priceImpactValidation
import io.novafoundation.nova.feature_swap_impl.domain.validation.rateNotExceedSlippage
import io.novafoundation.nova.feature_swap_impl.domain.validation.sufficientAmountOutToStayAboveED
import io.novafoundation.nova.feature_swap_impl.domain.validation.sufficientBalanceConsideringConsumersValidation
import io.novafoundation.nova.feature_swap_impl.domain.validation.swapFeeSufficientBalance
import io.novafoundation.nova.feature_swap_impl.domain.validation.swapSmallRemainingBalance
import io.novafoundation.nova.feature_swap_impl.domain.validation.utils.SharedQuoteValidationRetriever
import io.novafoundation.nova.feature_swap_impl.domain.validation.validations.CanReceiveAssetOutValidationFactory
import io.novafoundation.nova.feature_swap_impl.domain.validation.validations.intermediateReceivesMeetEDValidation
import io.novafoundation.nova.feature_swap_impl.domain.validation.validations.sufficientBalanceConsideringNonSufficientAssetsValidation
import io.novafoundation.nova.feature_swap_impl.domain.validation.validations.sufficientNativeBalanceToPayFeeConsideringED
import io.novafoundation.nova.feature_wallet_api.domain.interfaces.TokenRepository
import io.novafoundation.nova.feature_wallet_api.domain.model.FiatAmount
import io.novafoundation.nova.feature_wallet_api.domain.model.Token
import io.novafoundation.nova.feature_wallet_api.domain.validation.context.AssetsValidationContext
import io.novafoundation.nova.runtime.ext.fullId
import io.novafoundation.nova.runtime.multiNetwork.chain.model.Chain
import io.novafoundation.nova.runtime.multiNetwork.chain.model.ChainId
import io.novafoundation.nova.runtime.multiNetwork.chain.model.FullChainAssetId
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.withContext
import java.math.BigDecimal
class SwapInteractor(
private val priceImpactThresholds: PriceImpactThresholds,
private val swapService: SwapService,
private val tokenRepository: TokenRepository,
private val swapUpdateSystemFactory: SwapUpdateSystemFactory,
private val assetsValidationContextFactory: AssetsValidationContext.Factory,
private val canReceiveAssetOutValidationFactory: CanReceiveAssetOutValidationFactory,
) {
suspend fun getAllFeeTokens(swapFee: SwapFee): Map<FullChainAssetId, Token> {
val basicFees = swapFee.allBasicFees()
val chainAssets = basicFees.map { it.asset }
return tokenRepository.getTokens(chainAssets)
}
suspend fun calculateSegmentFiatPrices(swapFee: SwapFee): List<FiatAmount> {
return withContext(Dispatchers.Default) {
val basicFeesBySegment = swapFee.segments.map { it.fee.allBasicFees() }
val chainAssets = basicFeesBySegment.flatMap { segmentFees -> segmentFees.map { it.asset } }
val tokens = tokenRepository.getTokens(chainAssets)
val currency = tokens.values.first().currency
basicFeesBySegment.map { segmentBasicFees ->
val totalSegmentFees = segmentBasicFees.sumOf { basicFee ->
val token = tokens[basicFee.asset.fullId]
token?.planksToFiat(basicFee.amount) ?: BigDecimal.ZERO
}
FiatAmount(currency, totalSegmentFees)
}
}
}
suspend fun calculateTotalFiatPrice(swapFee: SwapFee): FiatAmount {
return withContext(Dispatchers.Default) {
val basicFees = swapFee.allBasicFees()
val chainAssets = basicFees.map { it.asset }
val tokens = tokenRepository.getTokens(chainAssets)
val totalFiat = basicFees.sumOf { basicFee ->
val token = tokens[basicFee.asset.fullId] ?: return@sumOf BigDecimal.ZERO
token.planksToFiat(basicFee.amount)
}
FiatAmount(
currency = tokens.values.first().currency,
price = totalFiat
)
}
}
suspend fun sync(coroutineScope: CoroutineScope) {
swapService.sync(coroutineScope)
}
suspend fun getUpdateSystem(chainFlow: Flow<Chain>, coroutineScope: CoroutineScope): UpdateSystem {
return swapUpdateSystemFactory.create(chainFlow, coroutineScope)
}
suspend fun quote(quoteArgs: SwapQuoteArgs, computationalScope: CoroutineScope): Result<SwapQuote> {
return swapService.quote(quoteArgs, computationalScope)
}
suspend fun executeSwap(calculatedFee: SwapFee): Flow<SwapProgress> = swapService.swap(calculatedFee)
suspend fun submitFirstSwapStep(calculatedFee: SwapFee): Result<SwapSubmissionResult> = swapService.submitFirstSwapStep(calculatedFee)
suspend fun warmUpSwapCommonlyUsedChains(computationalScope: CoroutineScope) {
swapService.warmUpCommonChains(computationalScope)
}
suspend fun estimateFee(executeArgs: SwapFeeArgs): SwapFee {
return swapService.estimateFee(executeArgs)
}
suspend fun slippageConfig(chainId: ChainId): SlippageConfig {
return swapService.defaultSlippageConfig(chainId)
}
fun runSubscriptions(metaAccount: MetaAccount): Flow<ReQuoteTrigger> {
return swapService.runSubscriptions(metaAccount)
}
suspend fun isDeepSwapAvailable(): Boolean {
return swapService.isDeepSwapAllowed()
}
fun validationSystem(): SwapValidationSystem {
val assetsValidationContext = assetsValidationContextFactory.create()
val sharedQuoteValidationRetriever = SharedQuoteValidationRetriever(swapService, assetsValidationContext)
return ValidationSystem {
positiveAmountIn()
positiveAmountOut()
canPayAllFees(assetsValidationContext)
enoughAssetInToPayForSwap(assetsValidationContext)
enoughAssetInToPayForSwapAndFee(assetsValidationContext)
sufficientNativeBalanceToPayFeeConsideringED(assetsValidationContext)
canReceiveAssetOutValidationFactory.canReceiveAssetOut(assetsValidationContext)
availableSlippage(swapService)
enoughLiquidity(sharedQuoteValidationRetriever)
rateNotExceedSlippage(sharedQuoteValidationRetriever)
intermediateReceivesMeetEDValidation(assetsValidationContext)
swapFeeSufficientBalance(assetsValidationContext)
sufficientBalanceConsideringNonSufficientAssetsValidation(assetsValidationContext)
sufficientBalanceConsideringConsumersValidation(assetsValidationContext)
swapSmallRemainingBalance(assetsValidationContext)
sufficientAmountOutToStayAboveED(assetsValidationContext)
priceImpactValidation(priceImpactThresholds)
}
}
}
@@ -0,0 +1,9 @@
package io.novafoundation.nova.feature_swap_impl.domain.swap
import io.novafoundation.nova.common.utils.Fraction
class PriceImpactThresholds(
val lowPriceImpact: Fraction,
val mediumPriceImpact: Fraction,
val highPriceImpact: Fraction
)
@@ -0,0 +1,960 @@
package io.novafoundation.nova.feature_swap_impl.domain.swap
import android.util.Log
import io.novafoundation.nova.common.data.memory.ComputationalCache
import io.novafoundation.nova.common.data.memory.SharedFlowCache
import io.novafoundation.nova.common.data.network.runtime.binding.BlockNumber
import io.novafoundation.nova.common.utils.Fraction
import io.novafoundation.nova.common.utils.Fraction.Companion.fractions
import io.novafoundation.nova.common.utils.atLeastZero
import io.novafoundation.nova.common.utils.filterNotNull
import io.novafoundation.nova.common.utils.flatMap
import io.novafoundation.nova.common.utils.flowOf
import io.novafoundation.nova.common.utils.forEachAsync
import io.novafoundation.nova.common.utils.graph.EdgeVisitFilter
import io.novafoundation.nova.common.utils.graph.Graph
import io.novafoundation.nova.common.utils.graph.Path
import io.novafoundation.nova.common.utils.graph.allEdges
import io.novafoundation.nova.common.utils.graph.create
import io.novafoundation.nova.common.utils.graph.findAllPossibleDestinations
import io.novafoundation.nova.common.utils.graph.hasOutcomingDirections
import io.novafoundation.nova.common.utils.graph.numberOfEdges
import io.novafoundation.nova.common.utils.graph.vertices
import io.novafoundation.nova.common.utils.isZero
import io.novafoundation.nova.common.utils.lazyAsync
import io.novafoundation.nova.common.utils.mapAsync
import io.novafoundation.nova.common.utils.measureExecution
import io.novafoundation.nova.common.utils.mergeIfMultiple
import io.novafoundation.nova.common.utils.orZero
import io.novafoundation.nova.common.utils.withFlowScope
import io.novafoundation.nova.feature_account_api.data.extrinsic.ExtrinsicService
import io.novafoundation.nova.feature_account_api.data.fee.FeePaymentCurrency
import io.novafoundation.nova.feature_account_api.data.fee.FeePaymentCurrency.Asset.Companion.toFeePaymentCurrency
import io.novafoundation.nova.feature_account_api.data.fee.FeePaymentProvider
import io.novafoundation.nova.feature_account_api.data.fee.FeePaymentProviderRegistry
import io.novafoundation.nova.feature_account_api.data.fee.capability.FastLookupCustomFeeCapability
import io.novafoundation.nova.feature_account_api.data.fee.fastLookupCustomFeeCapabilityOrDefault
import io.novafoundation.nova.feature_account_api.data.model.FeeBase
import io.novafoundation.nova.feature_account_api.data.model.SubstrateFeeBase
import io.novafoundation.nova.feature_account_api.data.signer.CallExecutionType
import io.novafoundation.nova.feature_account_api.data.signer.SignerProvider
import io.novafoundation.nova.feature_account_api.domain.interfaces.AccountRepository
import io.novafoundation.nova.feature_account_api.domain.model.MetaAccount
import io.novafoundation.nova.feature_swap_api.domain.model.AtomicSwapOperation
import io.novafoundation.nova.feature_swap_api.domain.model.AtomicSwapOperationArgs
import io.novafoundation.nova.feature_swap_api.domain.model.AtomicSwapOperationPrototype
import io.novafoundation.nova.feature_swap_api.domain.model.AtomicSwapOperationSubmissionArgs
import io.novafoundation.nova.feature_swap_api.domain.model.ReQuoteTrigger
import io.novafoundation.nova.feature_swap_api.domain.model.SlippageConfig
import io.novafoundation.nova.feature_swap_api.domain.model.SwapExecutionCorrection
import io.novafoundation.nova.feature_swap_api.domain.model.SwapExecutionEstimate
import io.novafoundation.nova.feature_swap_api.domain.model.SwapFee
import io.novafoundation.nova.feature_swap_api.domain.model.SwapFeeArgs
import io.novafoundation.nova.feature_swap_api.domain.model.SwapGraph
import io.novafoundation.nova.feature_swap_api.domain.model.SwapGraphEdge
import io.novafoundation.nova.feature_swap_api.domain.model.SwapLimit
import io.novafoundation.nova.feature_swap_api.domain.model.SwapProgress
import io.novafoundation.nova.feature_swap_api.domain.model.SwapProgressStep
import io.novafoundation.nova.feature_swap_api.domain.model.SwapQuote
import io.novafoundation.nova.feature_swap_api.domain.model.SwapQuoteArgs
import io.novafoundation.nova.feature_swap_api.domain.model.SwapSubmissionResult
import io.novafoundation.nova.feature_swap_api.domain.model.UsdConverter
import io.novafoundation.nova.feature_swap_api.domain.model.amountToLeaveOnOriginToPayTxFees
import io.novafoundation.nova.feature_swap_api.domain.model.replaceAmountIn
import io.novafoundation.nova.feature_swap_api.domain.model.totalFeeEnsuringSubmissionAsset
import io.novafoundation.nova.feature_swap_api.domain.swap.SwapService
import io.novafoundation.nova.feature_swap_core_api.data.paths.PathFeeEstimator
import io.novafoundation.nova.feature_swap_core_api.data.paths.PathQuoter
import io.novafoundation.nova.feature_swap_core_api.data.paths.model.PathRoughFeeEstimation
import io.novafoundation.nova.feature_swap_core_api.data.paths.model.QuotedEdge
import io.novafoundation.nova.feature_swap_core_api.data.paths.model.QuotedPath
import io.novafoundation.nova.feature_swap_core_api.data.paths.model.WeightBreakdown
import io.novafoundation.nova.feature_swap_core_api.data.paths.model.firstSegmentQuote
import io.novafoundation.nova.feature_swap_core_api.data.paths.model.firstSegmentQuotedAmount
import io.novafoundation.nova.feature_swap_core_api.data.paths.model.lastSegmentQuote
import io.novafoundation.nova.feature_swap_core_api.data.paths.model.lastSegmentQuotedAmount
import io.novafoundation.nova.feature_swap_core_api.data.primitive.model.SwapDirection
import io.novafoundation.nova.feature_swap_impl.BuildConfig
import io.novafoundation.nova.feature_swap_impl.data.assetExchange.AssetExchange
import io.novafoundation.nova.feature_swap_impl.data.assetExchange.FeePaymentProviderOverride
import io.novafoundation.nova.feature_swap_impl.data.assetExchange.ParentQuoterArgs
import io.novafoundation.nova.feature_swap_impl.data.assetExchange.SharedSwapSubscriptions
import io.novafoundation.nova.feature_swap_impl.data.assetExchange.assetConversion.AssetConversionExchangeFactory
import io.novafoundation.nova.feature_swap_impl.data.assetExchange.crossChain.CrossChainTransferAssetExchangeFactory
import io.novafoundation.nova.feature_swap_impl.data.assetExchange.hydraDx.HydraDxExchangeFactory
import io.novafoundation.nova.feature_wallet_api.data.network.blockhain.assets.AssetSourceRegistry
import io.novafoundation.nova.feature_wallet_api.data.network.blockhain.types.Balance
import io.novafoundation.nova.feature_wallet_api.domain.interfaces.TokenRepository
import io.novafoundation.nova.feature_wallet_api.domain.model.Token
import io.novafoundation.nova.feature_wallet_api.domain.model.planksFromFiatOrZero
import io.novafoundation.nova.feature_wallet_api.domain.model.withAmount
import io.novafoundation.nova.feature_wallet_api.presentation.formatters.formatPlanks
import io.novafoundation.nova.runtime.ext.Geneses
import io.novafoundation.nova.runtime.ext.assetConversionSupported
import io.novafoundation.nova.runtime.ext.fullId
import io.novafoundation.nova.runtime.ext.hydraDxSupported
import io.novafoundation.nova.runtime.ext.isUtility
import io.novafoundation.nova.runtime.ext.utilityAsset
import io.novafoundation.nova.runtime.ext.utilityAssetOf
import io.novafoundation.nova.runtime.multiNetwork.ChainRegistry
import io.novafoundation.nova.runtime.multiNetwork.ChainWithAsset
import io.novafoundation.nova.runtime.multiNetwork.ChainsById
import io.novafoundation.nova.runtime.multiNetwork.asset
import io.novafoundation.nova.runtime.multiNetwork.chain.model.Chain
import io.novafoundation.nova.runtime.multiNetwork.chain.model.ChainId
import io.novafoundation.nova.runtime.multiNetwork.chain.model.FullChainAssetId
import io.novafoundation.nova.runtime.multiNetwork.chainWithAssetOrNull
import io.novafoundation.nova.runtime.multiNetwork.chainsById
import io.novafoundation.nova.runtime.multiNetwork.enabledChainById
import io.novafoundation.nova.runtime.repository.ChainStateRepository
import io.novasama.substrate_sdk_android.hash.isPositive
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.catch
import kotlinx.coroutines.flow.debounce
import kotlinx.coroutines.flow.filter
import kotlinx.coroutines.flow.flow
import kotlinx.coroutines.flow.map
import kotlinx.coroutines.flow.onEach
import kotlinx.coroutines.flow.runningFold
import kotlinx.coroutines.withContext
import java.math.BigDecimal
import java.math.BigInteger
import java.math.MathContext
import kotlin.time.Duration
import kotlin.time.Duration.Companion.milliseconds
import kotlin.time.Duration.Companion.seconds
private const val ALL_DIRECTIONS_CACHE = "RealSwapService.ALL_DIRECTIONS"
private const val EXCHANGES_CACHE = "RealSwapService.EXCHANGES"
private const val EXTRINSIC_SERVICE_CACHE = "RealSwapService.ExtrinsicService"
private const val QUOTER_CACHE = "RealSwapService.QUOTER"
private const val NODE_VISIT_FILTER = "RealSwapService.NodeVisitFilter"
private const val SHARED_SUBSCRIPTIONS = "RealSwapService.SharedSubscriptions"
private val ADDITIONAL_ESTIMATE_BUFFER = 3.seconds
internal class RealSwapService(
private val assetConversionFactory: AssetConversionExchangeFactory,
private val hydraDxExchangeFactory: HydraDxExchangeFactory,
private val crossChainTransferFactory: CrossChainTransferAssetExchangeFactory,
private val computationalCache: ComputationalCache,
private val chainRegistry: ChainRegistry,
private val quoterFactory: PathQuoter.Factory,
private val extrinsicServiceFactory: ExtrinsicService.Factory,
private val defaultFeePaymentProviderRegistry: FeePaymentProviderRegistry,
private val assetSourceRegistry: AssetSourceRegistry,
private val accountRepository: AccountRepository,
private val tokenRepository: TokenRepository,
private val chainStateRepository: ChainStateRepository,
private val signerProvider: SignerProvider,
private val debug: Boolean = BuildConfig.DEBUG
) : SwapService {
override suspend fun warmUpCommonChains(computationScope: CoroutineScope): Result<Unit> {
return runCatching {
withContext(Dispatchers.Default) {
warmUpChain(Chain.Geneses.HYDRA_DX, computationScope)
warmUpChain(Chain.Geneses.POLKADOT_ASSET_HUB, computationScope)
}
}
}
private suspend fun warmUpChain(chainId: ChainId, computationScope: CoroutineScope) {
nodeVisitFilter(computationScope).warmUpChain(chainId)
}
override suspend fun sync(coroutineScope: CoroutineScope) {
Log.d("Swaps", "Syncing swap service")
exchangeRegistry(coroutineScope)
.allExchanges()
.forEachAsync { it.sync() }
}
override suspend fun assetsAvailableForSwap(
computationScope: CoroutineScope
): Flow<Set<FullChainAssetId>> {
return directionsGraph(computationScope).map { it.vertices() }
}
override suspend fun availableSwapDirectionsFor(
asset: Chain.Asset,
computationScope: CoroutineScope
): Flow<Set<FullChainAssetId>> {
return directionsGraph(computationScope).map {
val filter = nodeVisitFilter(computationScope)
measureExecution("findAllPossibleDestinations") {
it.findAllPossibleDestinations(asset.fullId, filter) - asset.fullId
}
}
}
override suspend fun hasAvailableSwapDirections(asset: Chain.Asset, computationScope: CoroutineScope): Flow<Boolean> {
return directionsGraph(computationScope).map { it.hasOutcomingDirections(asset.fullId) }
}
override suspend fun quote(
args: SwapQuoteArgs,
computationSharingScope: CoroutineScope
): Result<SwapQuote> {
return withContext(Dispatchers.Default) {
runCatching {
quoteInternal(args, computationSharingScope)
}.onFailure {
Log.e("RealSwapService", "Error while quoting", it)
}
}
}
override suspend fun estimateFee(executeArgs: SwapFeeArgs): SwapFee {
val atomicOperations = executeArgs.constructAtomicOperations()
val fees = atomicOperations.mapAsync { SwapFee.SwapSegment(it.estimateFee(), it) }
val convertedFees = fees.convertIntermediateSegmentsFeesToAssetIn(executeArgs.assetIn)
val firstOperation = atomicOperations.first()
return SwapFee(
segments = fees,
intermediateSegmentFeesInAssetIn = convertedFees,
additionalMaxAmountDeduction = firstOperation.additionalMaxAmountDeduction(),
).also(::logFee)
}
override suspend fun swap(calculatedFee: SwapFee): Flow<SwapProgress> {
val segments = calculatedFee.segments
val initialCorrection: Result<SwapExecutionCorrection?> = Result.success(null)
return flow {
segments.withIndex().fold(initialCorrection) { prevStepCorrection, (index, segment) ->
val (segmentFee, operation) = segment
prevStepCorrection.flatMap { correction ->
val displayData = operation.constructDisplayData()
val step = SwapProgressStep(index, displayData, operation)
emit(SwapProgress.StepStarted(step))
val newAmountIn = if (correction != null) {
correction.actualReceivedAmount - segmentFee.amountToLeaveOnOriginToPayTxFees()
} else {
val amountIn = operation.estimatedSwapLimit.estimatedAmountIn()
amountIn + calculatedFee.additionalAmountForSwap.amount
}
// We cannot execute buy for segments after first one since we deal with actualReceivedAmount there
val shouldReplaceBuyWithSell = correction != null
val actualSwapLimit = operation.estimatedSwapLimit.replaceAmountIn(newAmountIn, shouldReplaceBuyWithSell)
val segmentSubmissionArgs = AtomicSwapOperationSubmissionArgs(actualSwapLimit)
Log.d("SwapSubmission", "$displayData with $actualSwapLimit")
operation.execute(segmentSubmissionArgs).onFailure {
Log.e("SwapSubmission", "Swap failed on stage '$displayData'", it)
emit(SwapProgress.Failure(it, attemptedStep = step))
}
}
}.onSuccess {
emit(SwapProgress.Done)
}
}
}
override suspend fun submitFirstSwapStep(calculatedFee: SwapFee): Result<SwapSubmissionResult> {
val (_, operation) = calculatedFee.segments.firstOrNull() ?: return Result.failure(IllegalStateException("No segments"))
val amountIn = operation.estimatedSwapLimit.estimatedAmountIn() + calculatedFee.additionalAmountForSwap.amount
val actualSwapLimit = operation.estimatedSwapLimit.replaceAmountIn(amountIn, false)
val segmentSubmissionArgs = AtomicSwapOperationSubmissionArgs(actualSwapLimit)
return operation.submit(segmentSubmissionArgs)
}
private fun SwapLimit.estimatedAmountIn(): Balance {
return when (this) {
is SwapLimit.SpecifiedIn -> amountIn
is SwapLimit.SpecifiedOut -> amountInQuote
}
}
private suspend fun List<SwapFee.SwapSegment>.convertIntermediateSegmentsFeesToAssetIn(assetIn: Chain.Asset): FeeBase {
val convertedFees = foldRightIndexed(BigInteger.ZERO) { index, (operationFee, swapOperation), futureFeePlanks ->
val amountInToGetFeesForOut = if (futureFeePlanks.isPositive()) {
swapOperation.requiredAmountInToGetAmountOut(futureFeePlanks)
} else {
BigInteger.ZERO
}
amountInToGetFeesForOut + if (index != 0) {
// Ensure everything is in the same asset
operationFee.totalFeeEnsuringSubmissionAsset()
} else {
// First segment is not included
BigInteger.ZERO
}
}
return SubstrateFeeBase(convertedFees, assetIn)
}
private suspend fun SwapFeeArgs.constructAtomicOperations(): List<AtomicSwapOperation> {
var currentSwapTx: AtomicSwapOperation? = null
val finishedSwapTxs = mutableListOf<AtomicSwapOperation>()
executionPath.forEachIndexed { index, segmentExecuteArgs ->
val quotedEdge = segmentExecuteArgs.quotedSwapEdge
val operationArgs = AtomicSwapOperationArgs(
estimatedSwapLimit = SwapLimit(direction, quotedEdge.quotedAmount, slippage, quotedEdge.quote),
feePaymentCurrency = segmentExecuteArgs.quotedSwapEdge.edge.identifySegmentCurrency(
isFirstSegment = index == 0,
firstSegmentFees = firstSegmentFees,
),
)
// Initial case - begin first operation
if (currentSwapTx == null) {
currentSwapTx = quotedEdge.edge.beginOperation(operationArgs)
return@forEachIndexed
}
// Try to append segment to current swap tx
val maybeAppendedCurrentTx = quotedEdge.edge.appendToOperation(currentSwapTx!!, operationArgs)
currentSwapTx = if (maybeAppendedCurrentTx == null) {
finishedSwapTxs.add(currentSwapTx!!)
quotedEdge.edge.beginOperation(operationArgs)
} else {
maybeAppendedCurrentTx
}
}
finishedSwapTxs.add(currentSwapTx!!)
return finishedSwapTxs
}
private suspend fun Path<QuotedEdge<SwapGraphEdge>>.constructAtomicOperationPrototypes(): List<AtomicSwapOperationPrototype> {
var currentSwapTx: AtomicSwapOperationPrototype? = null
val finishedSwapTxs = mutableListOf<AtomicSwapOperationPrototype>()
forEach { quotedEdge ->
// Initial case - begin first operation
if (currentSwapTx == null) {
currentSwapTx = quotedEdge.edge.beginOperationPrototype()
return@forEach
}
// Try to append segment to current swap tx
val maybeAppendedCurrentTx = quotedEdge.edge.appendToOperationPrototype(currentSwapTx!!)
currentSwapTx = if (maybeAppendedCurrentTx == null) {
finishedSwapTxs.add(currentSwapTx!!)
quotedEdge.edge.beginOperationPrototype()
} else {
maybeAppendedCurrentTx
}
}
finishedSwapTxs.add(currentSwapTx!!)
return finishedSwapTxs
}
private suspend fun SwapGraphEdge.identifySegmentCurrency(
isFirstSegment: Boolean,
firstSegmentFees: FeePaymentCurrency
): FeePaymentCurrency {
return if (isFirstSegment) {
firstSegmentFees
} else {
// When executing intermediate segments, always pay in sending asset
chainRegistry.asset(from).toFeePaymentCurrency()
}
}
private suspend fun quoteInternal(
args: SwapQuoteArgs,
computationSharingScope: CoroutineScope
): SwapQuote {
val quotedTrade = quoteTrade(
chainAssetIn = args.tokenIn.configuration,
chainAssetOut = args.tokenOut.configuration,
amount = args.amount,
swapDirection = args.swapDirection,
computationSharingScope = computationSharingScope
)
val amountIn = quotedTrade.amountIn()
val amountOut = quotedTrade.amountOut()
val atomicOperationsEstimates = quotedTrade.estimateOperationsMaximumExecutionTime()
return SwapQuote(
amountIn = args.tokenIn.configuration.withAmount(amountIn),
amountOut = args.tokenOut.configuration.withAmount(amountOut),
priceImpact = args.calculatePriceImpact(amountIn, amountOut),
quotedPath = quotedTrade,
executionEstimate = SwapExecutionEstimate(atomicOperationsEstimates, ADDITIONAL_ESTIMATE_BUFFER),
direction = args.swapDirection,
)
}
private suspend fun QuotedTrade.estimateOperationsMaximumExecutionTime(): List<Duration> {
return path.constructAtomicOperationPrototypes()
.map { it.maximumExecutionTime() }
}
override suspend fun defaultSlippageConfig(chainId: ChainId): SlippageConfig {
return SlippageConfig.default()
}
override fun runSubscriptions(metaAccount: MetaAccount): Flow<ReQuoteTrigger> {
return withFlowScope { scope ->
val exchangeRegistry = exchangeRegistry(scope)
exchangeRegistry.allExchanges()
.map { it.runSubscriptions(metaAccount) }
.mergeIfMultiple()
}.debounce(500.milliseconds)
}
override suspend fun isDeepSwapAllowed(): Boolean {
val signer = signerProvider.rootSignerFor(accountRepository.getSelectedMetaAccount())
return when (signer.callExecutionType()) {
CallExecutionType.IMMEDIATE -> true
CallExecutionType.DELAYED -> false
}
}
private fun SwapQuoteArgs.calculatePriceImpact(amountIn: Balance, amountOut: Balance): Fraction {
val fiatIn = tokenIn.planksToFiat(amountIn)
val fiatOut = tokenOut.planksToFiat(amountOut)
return calculatePriceImpact(fiatIn, fiatOut)
}
private fun QuotedTrade.amountIn(): Balance {
return when (direction) {
SwapDirection.SPECIFIED_IN -> firstSegmentQuotedAmount
SwapDirection.SPECIFIED_OUT -> firstSegmentQuote
}
}
private fun QuotedTrade.amountOut(): Balance {
return when (direction) {
SwapDirection.SPECIFIED_IN -> lastSegmentQuote
SwapDirection.SPECIFIED_OUT -> lastSegmentQuotedAmount
}
}
private fun QuotedTrade.finalQuote(): Balance {
return when (direction) {
SwapDirection.SPECIFIED_IN -> lastSegmentQuote
SwapDirection.SPECIFIED_OUT -> firstSegmentQuote
}
}
private fun calculatePriceImpact(fiatIn: BigDecimal, fiatOut: BigDecimal): Fraction {
if (fiatIn.isZero || fiatOut.isZero) return Fraction.ZERO
val priceImpact = (BigDecimal.ONE - fiatOut / fiatIn).atLeastZero()
return priceImpact.fractions
}
private suspend fun directionsGraph(computationScope: CoroutineScope): Flow<SwapGraph> {
return computationalCache.useSharedFlow(ALL_DIRECTIONS_CACHE, computationScope) {
val exchangeRegistry = exchangeRegistry(computationScope)
val directionsByExchange = exchangeRegistry.allExchanges().map { exchange ->
flowOf { exchange.availableDirectSwapConnections() }
.catch {
emit(emptyList())
Log.e("RealSwapService", "Failed to fetch directions for exchange ${exchange::class.simpleName}", it)
}
}
directionsByExchange
.accumulateLists()
.filter { it.isNotEmpty() }
.map { Graph.create(it) }
.onEach { printGraphStats(it) }
}
}
private fun printGraphStats(graph: SwapGraph) {
if (!BuildConfig.DEBUG) return
val allEdges = graph.numberOfEdges()
val edgesByType = graph.allEdges().groupBy { it::class.simpleName }
val edgesByTypeStats = edgesByType.entries.joinToString { (type, typeEdges) ->
"$type: ${typeEdges.size}"
}
val message = """
=== Swap Graph Stats ===
All swap directions: $allEdges
$edgesByTypeStats
=== Swap Graph Stats ===
""".trimIndent()
Log.d("SwapService", message)
}
private suspend fun exchangeRegistry(computationScope: CoroutineScope): ExchangeRegistry {
return computationalCache.useCache(EXCHANGES_CACHE, computationScope) {
createExchangeRegistry(this)
}
}
private suspend fun nodeVisitFilter(computationScope: CoroutineScope): NodeVisitFilter {
return computationalCache.useCache(NODE_VISIT_FILTER, computationScope) {
NodeVisitFilter(
computationScope = this,
chainsById = chainRegistry.chainsById(),
selectedAccount = accountRepository.getSelectedMetaAccount()
)
}
}
private suspend fun extrinsicService(computationScope: CoroutineScope): ExtrinsicService {
return computationalCache.useCache(EXTRINSIC_SERVICE_CACHE, computationScope) {
createExtrinsicService(this)
}
}
private suspend fun createExchangeRegistry(coroutineScope: CoroutineScope): ExchangeRegistry {
return ExchangeRegistry(
singleChainExchanges = createIndividualChainExchanges(coroutineScope),
multiChainExchanges = listOf(
crossChainTransferFactory.create(createInnerSwapHost(coroutineScope))
)
)
}
private suspend fun createExtrinsicService(coroutineScope: CoroutineScope): ExtrinsicService {
val exchangeRegistry = exchangeRegistry(coroutineScope)
val feePaymentRegistry = exchangeRegistry.getFeePaymentRegistry()
return extrinsicServiceFactory.create(
ExtrinsicService.FeePaymentConfig(
coroutineScope = coroutineScope,
customFeePaymentRegistry = feePaymentRegistry
)
)
}
private suspend fun createIndividualChainExchanges(coroutineScope: CoroutineScope): Map<ChainId, AssetExchange> {
val host = createInnerSwapHost(coroutineScope)
return chainRegistry.enabledChainById().mapValues { (_, chain) ->
createSingleExchange(chain, host)
}
.filterNotNull()
}
private suspend fun createSingleExchange(
chain: Chain,
host: AssetExchange.SwapHost
): AssetExchange? {
val factory = when {
chain.swap.assetConversionSupported() -> assetConversionFactory
chain.swap.hydraDxSupported() -> hydraDxExchangeFactory
else -> null
}
return factory?.create(chain, host)
}
private suspend fun createInnerSwapHost(computationScope: CoroutineScope): InnerSwapHost {
val subscriptions = sharedSwapSubscriptions(computationScope)
return InnerSwapHost(computationScope, subscriptions)
}
private suspend fun sharedSwapSubscriptions(computationScope: CoroutineScope): SharedSwapSubscriptions {
return computationalCache.useCache(SHARED_SUBSCRIPTIONS, computationScope) {
RealSharedSwapSubscriptions(computationScope)
}
}
// Assumes each flow will have only single element
private fun <T> List<Flow<List<T>>>.accumulateLists(): Flow<List<T>> {
return mergeIfMultiple()
.runningFold(emptyList()) { acc, directions -> acc + directions }
}
private suspend fun quoteTrade(
chainAssetIn: Chain.Asset,
chainAssetOut: Chain.Asset,
amount: Balance,
swapDirection: SwapDirection,
computationSharingScope: CoroutineScope,
logQuotes: Boolean = true
): QuotedTrade {
val quoter = getPathQuoter(computationSharingScope)
val bestPathQuote = quoter.findBestPath(chainAssetIn, chainAssetOut, amount, swapDirection)
if (debug && logQuotes) {
logQuotes(bestPathQuote.candidates)
}
return bestPathQuote.bestPath
}
private suspend fun getPathQuoter(computationScope: CoroutineScope): PathQuoter<SwapGraphEdge> {
return computationalCache.useCache(QUOTER_CACHE, computationScope) {
val graphFlow = directionsGraph(computationScope)
val filter = nodeVisitFilter(computationScope)
quoterFactory.create(graphFlow, this, SwapPathFeeEstimator(), filter)
}
}
private inner class SwapPathFeeEstimator : PathFeeEstimator<SwapGraphEdge> {
override suspend fun roughlyEstimateFee(path: Path<QuotedEdge<SwapGraphEdge>>): PathRoughFeeEstimation {
// USDT is used to determine usd to selected currency rate without making a separate request to price api
val usdtOnAssetHub = chainRegistry.getUSDTOnAssetHub() ?: return PathRoughFeeEstimation.zero()
val operationPrototypes = path.constructAtomicOperationPrototypes()
val nativeAssetsSegments = operationPrototypes.allNativeAssets()
val assetIn = chainRegistry.asset(path.first().edge.from)
val assetOut = chainRegistry.asset(path.last().edge.to)
val prices = getTokens(assetIn = assetIn, assetOut = assetOut, usdTiedAsset = usdtOnAssetHub, fees = nativeAssetsSegments)
val totalFiat = operationPrototypes.estimateTotalFeeInFiat(prices, usdtOnAssetHub.fullId)
return PathRoughFeeEstimation(
inAssetIn = prices.fiatToPlanks(totalFiat, assetIn),
inAssetOut = prices.fiatToPlanks(totalFiat, assetOut)
)
}
private suspend fun ChainRegistry.getUSDTOnAssetHub(): Chain.Asset? {
val assetHub = getChain(Chain.Geneses.POLKADOT_ASSET_HUB)
return assetHub.assets.find { it.symbol.value == "USDT" }
}
private fun Map<FullChainAssetId, Token>.fiatToPlanks(fiat: BigDecimal, chainAsset: Chain.Asset): Balance {
val token = get(chainAsset.fullId) ?: return Balance.ZERO
return token.planksFromFiatOrZero(fiat)
}
private suspend fun getTokens(
assetIn: Chain.Asset,
assetOut: Chain.Asset,
usdTiedAsset: Chain.Asset,
fees: List<Chain.Asset>
): Map<FullChainAssetId, Token> {
val allTokensToRequestPrices = buildList {
addAll(fees)
add(assetIn)
add(usdTiedAsset)
add(assetOut)
}
return tokenRepository.getTokens(allTokensToRequestPrices)
}
private suspend fun List<AtomicSwapOperationPrototype>.allNativeAssets(): List<Chain.Asset> {
return map {
val chain = chainRegistry.getChain(it.fromChain)
chain.utilityAsset
}
}
private suspend fun List<AtomicSwapOperationPrototype>.estimateTotalFeeInFiat(
prices: Map<FullChainAssetId, Token>,
usdTiedAsset: FullChainAssetId
): BigDecimal {
return sumOf {
val nativeAssetId = FullChainAssetId.utilityAssetOf(it.fromChain)
val token = prices[nativeAssetId] ?: return@sumOf BigDecimal.ZERO
val usdConverter = PriceBasedUsdConverter(prices, nativeAssetId, usdTiedAsset)
val roughFee = it.roughlyEstimateNativeFee(usdConverter)
token.amountToFiat(roughFee)
}
}
private inner class PriceBasedUsdConverter(
private val prices: Map<FullChainAssetId, Token>,
private val nativeAsset: FullChainAssetId,
private val usdTiedAsset: FullChainAssetId,
) : UsdConverter {
val currencyToUsdRate = determineCurrencyToUsdRate()
override suspend fun nativeAssetEquivalentOf(usdAmount: Double): BigDecimal {
val priceInCurrency = prices[nativeAsset]?.coinRate?.rate ?: return BigDecimal.ZERO
val priceInUsd = priceInCurrency * currencyToUsdRate
return usdAmount.toBigDecimal() / priceInUsd
}
private fun determineCurrencyToUsdRate(): BigDecimal {
val usdTiedAssetPrice = prices[usdTiedAsset] ?: return BigDecimal.ZERO
val rate = usdTiedAssetPrice.coinRate?.rate.orZero()
if (rate.isZero) return BigDecimal.ZERO
return BigDecimal.ONE.divide(rate, MathContext.DECIMAL64)
}
}
}
private inner class InnerSwapHost(
override val scope: CoroutineScope,
override val sharedSubscriptions: SharedSwapSubscriptions
) : AssetExchange.SwapHost {
override suspend fun quote(quoteArgs: ParentQuoterArgs): Balance {
return quoteTrade(
chainAssetIn = quoteArgs.chainAssetIn,
chainAssetOut = quoteArgs.chainAssetOut,
amount = quoteArgs.amount,
swapDirection = quoteArgs.swapDirection,
computationSharingScope = scope,
logQuotes = false
).finalQuote()
}
override suspend fun extrinsicService(): ExtrinsicService {
return extrinsicService(scope)
}
}
private fun logFee(fee: SwapFee) {
val route = fee.segments.joinToString(separator = "\n") { segment ->
val allFees = buildList {
add(segment.fee.submissionFee)
addAll(segment.fee.postSubmissionFees.paidByAccount)
addAll(segment.fee.postSubmissionFees.paidFromAmount)
}
allFees.joinToString { "${it.amount.formatPlanks(it.asset)} (${it.debugLabel})" }
}
Log.d("Swaps", "---- Fees -----")
Log.d("Swaps", route)
Log.d("Swaps", "---- End Fees -----")
}
private suspend fun logQuotes(quotedTrades: List<QuotedTrade>) {
val allCandidates = quotedTrades.sortedDescending()
.map { trade -> formatTrade(trade) }
.joinToString(separator = "\n")
Log.d("RealSwapService", "-------- New quote ----------")
Log.d("RealSwapService", allCandidates)
Log.d("RealSwapService", "-------- Done quote ----------\n\n\n")
}
private suspend fun formatTrade(trade: QuotedTrade): String {
return buildString {
val weightBreakdown = WeightBreakdown.fromQuotedPath(trade)
trade.path.zip(weightBreakdown.individualWeights).onEachIndexed { index, (quotedSwapEdge, weight) ->
val amountIn: Balance
val amountOut: Balance
when (trade.direction) {
SwapDirection.SPECIFIED_IN -> {
amountIn = quotedSwapEdge.quotedAmount
amountOut = quotedSwapEdge.quote
}
SwapDirection.SPECIFIED_OUT -> {
amountIn = quotedSwapEdge.quote
amountOut = quotedSwapEdge.quotedAmount
}
}
if (index == 0) {
val assetIn = chainRegistry.asset(quotedSwapEdge.edge.from)
val initialAmount = amountIn.formatPlanks(assetIn)
append(initialAmount)
if (trade.direction == SwapDirection.SPECIFIED_OUT) {
val roughFeesInAssetIn = trade.roughFeeEstimation.inAssetIn
val roughFeesInAssetInAmount = roughFeesInAssetIn.formatPlanks(assetIn)
append(" (+$roughFeesInAssetInAmount fees) ")
}
}
append(" --- ${quotedSwapEdge.edge.debugLabel()} (w: $weight)---> ")
val assetOut = chainRegistry.asset(quotedSwapEdge.edge.to)
val outAmount = amountOut.formatPlanks(assetOut)
append(outAmount)
if (index == trade.path.size - 1) {
if (trade.direction == SwapDirection.SPECIFIED_IN) {
val roughFeesInAssetOut = trade.roughFeeEstimation.inAssetOut
val roughFeesInAssetOutAmount = roughFeesInAssetOut.formatPlanks(assetOut)
append(" (-$roughFeesInAssetOutAmount fees, w: ${weightBreakdown.total})")
}
}
}
}
}
private inner class ExchangeRegistry(
private val singleChainExchanges: Map<ChainId, AssetExchange>,
private val multiChainExchanges: List<AssetExchange>,
) {
private val feePaymentRegistry = SwapFeePaymentRegistry()
fun getFeePaymentRegistry(): FeePaymentProviderRegistry {
return feePaymentRegistry
}
fun allExchanges(): List<AssetExchange> {
return buildList {
addAll(singleChainExchanges.values)
addAll(multiChainExchanges)
}
}
private inner class SwapFeePaymentRegistry : FeePaymentProviderRegistry {
private val paymentRegistryOverrides = createFeePaymentOverrides()
override suspend fun providerFor(chainId: ChainId): FeePaymentProvider {
return paymentRegistryOverrides.find { it.chain.id == chainId }?.provider
?: defaultFeePaymentProviderRegistry.providerFor(chainId)
}
private fun createFeePaymentOverrides(): List<FeePaymentProviderOverride> {
return buildList {
singleChainExchanges.values.onEach { singleChainExchange ->
addAll(singleChainExchange.feePaymentOverrides())
}
multiChainExchanges.onEach { multiChainExchange ->
addAll(multiChainExchange.feePaymentOverrides())
}
}
}
}
}
/**
* Check that it is possible to pay fees in moving asset
*/
private inner class NodeVisitFilter(
val computationScope: CoroutineScope,
val chainsById: ChainsById,
val selectedAccount: MetaAccount,
) : EdgeVisitFilter<SwapGraphEdge> {
private val feePaymentCapabilityCache: MutableMap<ChainId, FastLookupCustomFeeCapability> = mutableMapOf()
private val callExecutionType = lazyAsync {
signerProvider.rootSignerFor(selectedAccount)
.callExecutionType()
}
suspend fun warmUpChain(chainId: ChainId) {
getFeeCustomFeeCapability(chainId)
}
override suspend fun shouldVisit(edge: SwapGraphEdge, pathPredecessor: SwapGraphEdge?): Boolean {
val chainAndAssetOut = chainsById.chainWithAssetOrNull(edge.to) ?: return false
// User should have account on destination
if (!selectedAccount.hasAccountIn(chainAndAssetOut.chain)) return false
// First path segments don't have any extra restrictions
if (pathPredecessor == null) return true
// Second and subsequent edges are subject to checking whether we can execute them one by one immediately
if (!canExecuteIntermediateEdgeSequentially(edge, pathPredecessor)) return false
// We don't (yet) handle edges that doesn't allow to transfer whole account balance out
if (!edge.canTransferOutWholeAccountBalance()) return false
// Destination asset must be sufficient
if (!isSufficient(chainAndAssetOut)) return false
val chainAndAssetIn = chainsById.chainWithAssetOrNull(edge.from) ?: return false
// Since we allow insufficient asset out in paths with length 1, we want to reject paths with length > 1
// by checking sufficiency of assetIn (which was assetOut in the previous segment)
if (!isSufficient(chainAndAssetIn)) return false
// Besides checks above, utility assets don't have any other restrictions
if (edge.from.isUtility) return true
// Edge might request us to ignore the default requirement based on its direct predecessor
if (edge.predecessorHandlesFees(pathPredecessor)) return true
val feeCapability = getFeeCustomFeeCapability(edge.from.chainId)
return feeCapability != null && feeCapability.canPayFeeInNonUtilityToken(edge.from.assetId) &&
edge.canPayNonNativeFeesInIntermediatePosition()
}
private suspend fun canExecuteIntermediateEdgeSequentially(edge: SwapGraphEdge, predecessor: SwapGraphEdge): Boolean {
// If account can execute operations immediately - we can execute anything sequentially
if (callExecutionType.get() == CallExecutionType.IMMEDIATE) return true
// Otherwise it is only possible to do when the edges is merged with predecessor. If it does not - it will require a separate operation
// And doing a separate operation is not possible since execution type is DELAYED
return edge.canAppendToPredecessor(predecessor)
}
private fun isSufficient(chainAndAsset: ChainWithAsset): Boolean {
val balance = assetSourceRegistry.sourceFor(chainAndAsset.asset).balance
return balance.isSelfSufficient(chainAndAsset.asset)
}
private suspend fun getFeeCustomFeeCapability(chainId: ChainId): FastLookupCustomFeeCapability {
return feePaymentCapabilityCache.getOrPut(chainId) {
createFastLookupFeeCapability(chainId, computationScope)
}
}
private suspend fun createFastLookupFeeCapability(chainId: ChainId, computationScope: CoroutineScope): FastLookupCustomFeeCapability {
val feePaymentRegistry = exchangeRegistry(computationScope).getFeePaymentRegistry()
return feePaymentRegistry.providerFor(chainId).fastLookupCustomFeeCapabilityOrDefault()
}
}
private inner class RealSharedSwapSubscriptions(
private val coroutineScope: CoroutineScope,
) : SharedSwapSubscriptions, CoroutineScope by coroutineScope {
private val blockNumberCache = SharedFlowCache<ChainId, BlockNumber>(coroutineScope) { chainId ->
chainStateRepository.currentRemoteBlockNumberFlow(chainId)
}
override suspend fun blockNumber(chainId: ChainId): Flow<BlockNumber> {
return blockNumberCache.getOrCompute(chainId)
}
}
}
private typealias QuotedTrade = QuotedPath<SwapGraphEdge>
abstract class BaseSwapGraphEdge(
val fromAsset: Chain.Asset,
val toAsset: Chain.Asset
) : SwapGraphEdge {
final override val from: FullChainAssetId = fromAsset.fullId
final override val to: FullChainAssetId = toAsset.fullId
}
@@ -0,0 +1,31 @@
package io.novafoundation.nova.feature_swap_impl.domain.validation
import io.novafoundation.nova.common.utils.Fraction
import io.novafoundation.nova.feature_swap_api.domain.model.SwapFee
import io.novafoundation.nova.feature_swap_api.domain.model.SwapLimit
import io.novafoundation.nova.feature_swap_api.domain.model.SwapQuote
import io.novafoundation.nova.feature_swap_api.domain.model.createAggregated
import io.novafoundation.nova.feature_swap_impl.presentation.common.state.SwapState
import io.novafoundation.nova.feature_wallet_api.domain.model.ChainAssetWithAmount
data class SwapValidationPayload(
val fee: SwapFee,
val swapQuote: SwapQuote,
val slippage: Fraction,
) {
val amountIn: ChainAssetWithAmount = swapQuote.amountIn
val amountOut: ChainAssetWithAmount = swapQuote.amountOut
}
fun SwapValidationPayload.estimatedSwapLimit(): SwapLimit {
val firstLimit = fee.segments.first().operation.estimatedSwapLimit
val lastLimit = fee.segments.last().operation.estimatedSwapLimit
return SwapLimit.createAggregated(firstLimit, lastLimit)
}
fun SwapValidationPayload.toSwapState(): SwapState {
return SwapState(swapQuote, fee, slippage)
}
@@ -0,0 +1,88 @@
package io.novafoundation.nova.feature_swap_impl.domain.validation
import io.novafoundation.nova.common.utils.Fraction
import io.novafoundation.nova.feature_wallet_api.data.network.blockhain.types.Balance
import io.novafoundation.nova.feature_wallet_api.domain.model.ChainAssetWithAmount
import io.novafoundation.nova.feature_wallet_api.domain.validation.InsufficientBalanceToStayAboveEDError
import io.novafoundation.nova.runtime.multiNetwork.ChainWithAsset
import io.novafoundation.nova.runtime.multiNetwork.chain.model.Chain
import java.math.BigDecimal
sealed class SwapValidationFailure {
object NonPositiveAmount : SwapValidationFailure()
class HighPriceImpact(val priceImpact: Fraction) : SwapValidationFailure()
class InvalidSlippage(val minSlippage: Fraction, val maxSlippage: Fraction) : SwapValidationFailure()
class NewRateExceededSlippage(
val assetIn: Chain.Asset,
val assetOut: Chain.Asset,
val selectedRate: BigDecimal,
val newRate: BigDecimal
) : SwapValidationFailure()
object NotEnoughLiquidity : SwapValidationFailure()
sealed class NotEnoughFunds : SwapValidationFailure() {
class ToPayFeeAndStayAboveED(
override val asset: Chain.Asset,
override val errorModel: InsufficientBalanceToStayAboveEDError.ErrorModel
) : NotEnoughFunds(), InsufficientBalanceToStayAboveEDError
object InUsedAsset : NotEnoughFunds()
}
class AmountOutIsTooLowToStayAboveED(
val asset: Chain.Asset,
val amountInPlanks: Balance,
val existentialDeposit: Balance
) : SwapValidationFailure()
class IntermediateAmountOutIsTooLowToStayAboveED(
val asset: Chain.Asset,
val existentialDeposit: Balance,
val amount: Balance
) : SwapValidationFailure()
class CannotReceiveAssetOut(
val destination: ChainWithAsset,
val requiredNativeAssetOnChainOut: ChainAssetWithAmount
) : SwapValidationFailure()
sealed class InsufficientBalance : SwapValidationFailure() {
class BalanceNotConsiderInsufficientReceiveAsset(
val assetIn: Chain.Asset,
val assetOut: Chain.Asset,
val existentialDeposit: Balance
) : SwapValidationFailure()
class BalanceNotConsiderConsumers(
val assetIn: Chain.Asset,
val assetInED: Balance,
val feeAsset: Chain.Asset,
val fee: Balance
) : SwapValidationFailure()
class CannotPayFeeDueToAmount(
val assetIn: Chain.Asset,
val feeAmount: BigDecimal,
val maxSwapAmount: BigDecimal
) : SwapValidationFailure()
class CannotPayFee(
val feeAsset: Chain.Asset,
val balance: Balance,
val fee: Balance
) : SwapValidationFailure()
}
class TooSmallRemainingBalance(
val assetIn: Chain.Asset,
val remainingBalance: Balance,
val assetInExistentialDeposit: Balance
) : SwapValidationFailure()
}
@@ -0,0 +1,126 @@
package io.novafoundation.nova.feature_swap_impl.domain.validation
import io.novafoundation.nova.common.utils.atLeastZero
import io.novafoundation.nova.common.validation.Validation
import io.novafoundation.nova.common.validation.ValidationSystem
import io.novafoundation.nova.common.validation.ValidationSystemBuilder
import io.novafoundation.nova.feature_account_api.data.model.amountByExecutingAccount
import io.novafoundation.nova.feature_swap_api.domain.swap.SwapService
import io.novafoundation.nova.feature_swap_impl.domain.swap.PriceImpactThresholds
import io.novafoundation.nova.feature_swap_impl.domain.validation.utils.SharedQuoteValidationRetriever
import io.novafoundation.nova.feature_swap_impl.domain.validation.validations.SwapCanPayExtraFeesValidation
import io.novafoundation.nova.feature_swap_impl.domain.validation.validations.SwapDoNotLooseAssetInDustValidation
import io.novafoundation.nova.feature_swap_impl.domain.validation.validations.SwapEnoughLiquidityValidation
import io.novafoundation.nova.feature_swap_impl.domain.validation.validations.SwapPriceImpactValidation
import io.novafoundation.nova.feature_swap_impl.domain.validation.validations.SwapRateChangesValidation
import io.novafoundation.nova.feature_swap_impl.domain.validation.validations.SwapSlippageRangeValidation
import io.novafoundation.nova.feature_swap_impl.domain.validation.validations.sufficientAmountOutToStayAboveEDValidation
import io.novafoundation.nova.feature_wallet_api.domain.model.amountFromPlanks
import io.novafoundation.nova.feature_wallet_api.domain.model.decimalAmount
import io.novafoundation.nova.feature_wallet_api.domain.validation.context.AssetsValidationContext
import io.novafoundation.nova.feature_wallet_api.domain.validation.positiveAmount
import io.novafoundation.nova.feature_wallet_api.domain.validation.sufficientBalanceConsideringConsumersValidation
import io.novafoundation.nova.feature_wallet_api.domain.validation.sufficientBalanceGeneric
typealias SwapValidationSystem = ValidationSystem<SwapValidationPayload, SwapValidationFailure>
typealias SwapValidation = Validation<SwapValidationPayload, SwapValidationFailure>
typealias SwapValidationSystemBuilder = ValidationSystemBuilder<SwapValidationPayload, SwapValidationFailure>
fun SwapValidationSystemBuilder.priceImpactValidation(
priceImpactThresholds: PriceImpactThresholds
) = validate(SwapPriceImpactValidation(priceImpactThresholds))
fun SwapValidationSystemBuilder.availableSlippage(
swapService: SwapService
) = validate(SwapSlippageRangeValidation(swapService))
fun SwapValidationSystemBuilder.swapFeeSufficientBalance(
assetsValidationContext: AssetsValidationContext
) = validate(SwapCanPayExtraFeesValidation(assetsValidationContext))
fun SwapValidationSystemBuilder.swapSmallRemainingBalance(
assetsValidationContext: AssetsValidationContext
) = validate(
SwapDoNotLooseAssetInDustValidation(assetsValidationContext)
)
fun SwapValidationSystemBuilder.sufficientBalanceConsideringConsumersValidation(
assetsValidationContext: AssetsValidationContext
) = sufficientBalanceConsideringConsumersValidation(
assetsValidationContext = assetsValidationContext,
assetExtractor = { it.amountIn.chainAsset },
feeExtractor = { it.fee.totalFeeAmount(it.amountIn.chainAsset) },
amountExtractor = { it.amountIn.amount },
error = { payload, existentialDeposit ->
SwapValidationFailure.InsufficientBalance.BalanceNotConsiderConsumers(
assetIn = payload.amountIn.chainAsset,
assetInED = existentialDeposit,
fee = payload.fee.initialSubmissionFee.amountByExecutingAccount,
feeAsset = payload.fee.initialSubmissionFee.asset
)
}
)
fun SwapValidationSystemBuilder.rateNotExceedSlippage(
sharedQuoteValidationRetriever: SharedQuoteValidationRetriever
) = validate(
SwapRateChangesValidation(sharedQuoteValidationRetriever)
)
fun SwapValidationSystemBuilder.enoughLiquidity(
sharedQuoteValidationRetriever: SharedQuoteValidationRetriever
) = validate(
SwapEnoughLiquidityValidation(sharedQuoteValidationRetriever)
)
fun SwapValidationSystemBuilder.canPayAllFees(
assetsValidationContext: AssetsValidationContext
) = validate(
SwapCanPayExtraFeesValidation(assetsValidationContext)
)
fun SwapValidationSystemBuilder.enoughAssetInToPayForSwap(
assetsValidationContext: AssetsValidationContext
) = sufficientBalanceGeneric(
available = { assetsValidationContext.getAsset(it.amountIn.chainAsset).transferable },
amount = { it.amountIn.decimalAmount },
error = { SwapValidationFailure.NotEnoughFunds.InUsedAsset }
)
fun SwapValidationSystemBuilder.enoughAssetInToPayForSwapAndFee(
assetsValidationContext: AssetsValidationContext
) = sufficientBalanceGeneric(
available = {
val transferable = assetsValidationContext.getAsset(it.amountIn.chainAsset).transferable
val extraDeductionPlanks = it.fee.additionalMaxAmountDeduction.fromCountedTowardsEd
val extraDeduction = it.amountIn.chainAsset.amountFromPlanks(extraDeductionPlanks)
(transferable - extraDeduction).atLeastZero()
},
amount = { it.amountIn.decimalAmount },
fee = {
val planks = it.fee.totalFeeAmount(it.amountIn.chainAsset)
it.amountIn.chainAsset.amountFromPlanks(planks)
},
error = {
SwapValidationFailure.InsufficientBalance.CannotPayFeeDueToAmount(
assetIn = it.payload.amountIn.chainAsset,
feeAmount = it.fee,
maxSwapAmount = it.maxUsable
)
}
)
fun SwapValidationSystemBuilder.sufficientAmountOutToStayAboveED(
assetsValidationContext: AssetsValidationContext
) = sufficientAmountOutToStayAboveEDValidation(assetsValidationContext)
fun SwapValidationSystemBuilder.positiveAmountIn() = positiveAmount(
amount = { it.amountIn.decimalAmount },
error = { SwapValidationFailure.NonPositiveAmount }
)
fun SwapValidationSystemBuilder.positiveAmountOut() = positiveAmount(
amount = { it.amountOut.decimalAmount },
error = { SwapValidationFailure.NonPositiveAmount }
)
@@ -0,0 +1,34 @@
package io.novafoundation.nova.feature_swap_impl.domain.validation.utils
import io.novafoundation.nova.feature_swap_api.domain.model.SwapQuote
import io.novafoundation.nova.feature_swap_api.domain.model.SwapQuoteArgs
import io.novafoundation.nova.feature_swap_api.domain.swap.SwapService
import io.novafoundation.nova.feature_swap_core_api.data.paths.model.quotedAmount
import io.novafoundation.nova.feature_swap_impl.domain.validation.SwapValidationPayload
import io.novafoundation.nova.feature_wallet_api.domain.validation.context.AssetsValidationContext
import kotlinx.coroutines.CoroutineScope
import kotlin.coroutines.coroutineContext
class SharedQuoteValidationRetriever(
private val swapService: SwapService,
private val assetsValidationContext: AssetsValidationContext,
) {
private var result: Result<SwapQuote>? = null
suspend fun retrieveQuote(value: SwapValidationPayload): Result<SwapQuote> {
if (result == null) {
val assetIn = assetsValidationContext.getAsset(value.amountIn.chainAsset)
val assetOut = assetsValidationContext.getAsset(value.amountOut.chainAsset)
val amount = value.swapQuote.quotedPath.quotedAmount
val direction = value.swapQuote.direction
val quoteArgs = SwapQuoteArgs(assetIn.token, assetOut.token, amount, direction)
result = swapService.quote(quoteArgs, CoroutineScope(coroutineContext))
}
return result!!
}
}
@@ -0,0 +1,93 @@
package io.novafoundation.nova.feature_swap_impl.domain.validation.validations
import io.novafoundation.nova.common.di.scope.FeatureScope
import io.novafoundation.nova.common.validation.ValidationStatus
import io.novafoundation.nova.common.validation.isTrueOrError
import io.novafoundation.nova.common.validation.valid
import io.novafoundation.nova.feature_account_api.domain.interfaces.AccountRepository
import io.novafoundation.nova.feature_swap_impl.domain.validation.SwapValidation
import io.novafoundation.nova.feature_swap_impl.domain.validation.SwapValidationFailure
import io.novafoundation.nova.feature_swap_impl.domain.validation.SwapValidationPayload
import io.novafoundation.nova.feature_swap_impl.domain.validation.SwapValidationSystemBuilder
import io.novafoundation.nova.feature_wallet_api.data.repository.AccountInfoRepository
import io.novafoundation.nova.feature_wallet_api.domain.model.withAmount
import io.novafoundation.nova.feature_wallet_api.domain.validation.context.AssetsValidationContext
import io.novafoundation.nova.feature_wallet_api.domain.validation.context.getExistentialDeposit
import io.novafoundation.nova.runtime.ext.utilityAsset
import io.novafoundation.nova.runtime.multiNetwork.ChainRegistry
import io.novafoundation.nova.runtime.multiNetwork.ChainWithAsset
import javax.inject.Inject
@FeatureScope
class CanReceiveAssetOutValidationFactory @Inject constructor(
private val accountInfoRepository: AccountInfoRepository,
private val chainRegistry: ChainRegistry,
private val accountRepository: AccountRepository,
) {
context(SwapValidationSystemBuilder)
fun canReceiveAssetOut(validationContext: AssetsValidationContext) {
validate(CanReceiveAssetOutValidation(accountInfoRepository, chainRegistry, accountRepository, validationContext))
}
}
/**
* 1. asset out is sufficient OR
*
* 2. remaining providers (minus 1 if asset in is on the same chain, sufficient and dusted) is positive
*
* Otherwise it is not possible to receive insufficient assets on destination
*/
class CanReceiveAssetOutValidation(
private val accountInfoRepository: AccountInfoRepository,
private val chainRegistry: ChainRegistry,
private val accountRepository: AccountRepository,
private val assetsValidationContext: AssetsValidationContext,
) : SwapValidation {
override suspend fun validate(value: SwapValidationPayload): ValidationStatus<SwapValidationFailure> {
val isAssetOutSufficient = assetsValidationContext.isAssetSufficient(value.amountOut.chainAsset)
if (isAssetOutSufficient) return valid()
val chainAssetOut = value.amountOut.chainAsset
val chainOut = chainRegistry.getChain(chainAssetOut.chainId)
val metaAccount = accountRepository.getSelectedMetaAccount()
val recipientAccountId = metaAccount.accountIdIn(chainOut) ?: return valid()
val destinationAccountInfo = accountInfoRepository.getAccountInfo(chainOut.id, recipientAccountId)
val providersDecrease = if (swapDecreasesProviders(value)) 1 else 0
val remainingProviders = destinationAccountInfo.providers.toInt() - providersDecrease
return (remainingProviders > 0) isTrueOrError {
val destinationChainNativeAsset = chainOut.utilityAsset
val destinationChainNativeAssetEd = assetsValidationContext.getExistentialDeposit(destinationChainNativeAsset)
SwapValidationFailure.CannotReceiveAssetOut(
destination = ChainWithAsset(chainOut, chainAssetOut),
requiredNativeAssetOnChainOut = destinationChainNativeAsset.withAmount(destinationChainNativeAssetEd)
)
}
}
private suspend fun swapDecreasesProviders(
value: SwapValidationPayload
): Boolean {
val assetIn = value.amountIn.chainAsset
val assetOut = value.amountOut.chainAsset
// Asset in does not affect providers on destination chain when its on different chain
if (assetIn.chainId != assetOut.chainId) return false
// If asset in is not sufficient, it cannot influence number of providers even if dusted
if (assetsValidationContext.isAssetSufficient(assetIn)) return false
val assetInBalance = assetsValidationContext.getAsset(assetIn)
val assetInEd = assetsValidationContext.getExistentialDeposit(assetIn)
val swapDustsAssetIn = assetInBalance.balanceCountedTowardsEDInPlanks - value.amountIn.amount < assetInEd
return swapDustsAssetIn
}
}
@@ -0,0 +1,41 @@
package io.novafoundation.nova.feature_swap_impl.domain.validation.validations
import io.novafoundation.nova.common.validation.ValidationStatus
import io.novafoundation.nova.common.validation.valid
import io.novafoundation.nova.common.validation.validationError
import io.novafoundation.nova.feature_swap_api.domain.model.allFeeAssets
import io.novafoundation.nova.feature_swap_impl.domain.validation.SwapValidation
import io.novafoundation.nova.feature_swap_impl.domain.validation.SwapValidationFailure
import io.novafoundation.nova.feature_swap_impl.domain.validation.SwapValidationFailure.InsufficientBalance
import io.novafoundation.nova.feature_swap_impl.domain.validation.SwapValidationPayload
import io.novafoundation.nova.feature_wallet_api.domain.validation.context.AssetsValidationContext
import io.novafoundation.nova.runtime.ext.fullId
/**
* Validation that checks whether the user can pay all the fees in assets other then assetIn
* Asset in fees is checked in a separate validation that also takes swap amount into account
*/
class SwapCanPayExtraFeesValidation(
private val assetsValidationContext: AssetsValidationContext
) : SwapValidation {
override suspend fun validate(value: SwapValidationPayload): ValidationStatus<SwapValidationFailure> {
val extraFeeAssets = value.fee.firstSegmentFee.allFeeAssets()
// asset in fee is checked in a separate validation
.filter { it.fullId != value.amountIn.chainAsset.fullId }
extraFeeAssets.onEach { feeChainAsset ->
val feeAsset = assetsValidationContext.getAsset(feeChainAsset)
val totalFee = value.fee.totalFeeAmount(feeChainAsset)
val availableBalance = feeAsset.transferableInPlanks
if (availableBalance < totalFee) {
return InsufficientBalance.CannotPayFee(feeChainAsset, availableBalance, totalFee)
.validationError()
}
}
return valid()
}
}
@@ -0,0 +1,34 @@
package io.novafoundation.nova.feature_swap_impl.domain.validation.validations
import io.novafoundation.nova.common.validation.ValidationStatus
import io.novafoundation.nova.common.validation.valid
import io.novafoundation.nova.common.validation.validationError
import io.novafoundation.nova.feature_swap_impl.domain.validation.SwapValidation
import io.novafoundation.nova.feature_swap_impl.domain.validation.SwapValidationFailure
import io.novafoundation.nova.feature_swap_impl.domain.validation.SwapValidationFailure.TooSmallRemainingBalance
import io.novafoundation.nova.feature_swap_impl.domain.validation.SwapValidationPayload
import io.novafoundation.nova.feature_wallet_api.domain.validation.context.AssetsValidationContext
import io.novafoundation.nova.feature_wallet_api.domain.validation.context.getExistentialDeposit
import io.novasama.substrate_sdk_android.hash.isPositive
class SwapDoNotLooseAssetInDustValidation(
private val assetsValidationContext: AssetsValidationContext,
) : SwapValidation {
override suspend fun validate(value: SwapValidationPayload): ValidationStatus<SwapValidationFailure> {
val chainAssetIn = value.amountIn.chainAsset
val balanceCountedTowardsEd = assetsValidationContext.getAsset(chainAssetIn).balanceCountedTowardsEDInPlanks
val swapAmount = value.amountIn.amount
val assetInExistentialDeposit = assetsValidationContext.getExistentialDeposit(chainAssetIn)
val totalFees = value.fee.totalFeeAmount(chainAssetIn)
val remainingBalance = balanceCountedTowardsEd - swapAmount - totalFees
if (remainingBalance.isPositive() && remainingBalance < assetInExistentialDeposit) {
return TooSmallRemainingBalance(chainAssetIn, remainingBalance, assetInExistentialDeposit).validationError()
}
return valid()
}
}
@@ -0,0 +1,22 @@
package io.novafoundation.nova.feature_swap_impl.domain.validation.validations
import io.novafoundation.nova.common.validation.ValidationStatus
import io.novafoundation.nova.common.validation.validOrError
import io.novafoundation.nova.feature_swap_impl.domain.validation.SwapValidation
import io.novafoundation.nova.feature_swap_impl.domain.validation.SwapValidationFailure
import io.novafoundation.nova.feature_swap_impl.domain.validation.SwapValidationFailure.NotEnoughLiquidity
import io.novafoundation.nova.feature_swap_impl.domain.validation.SwapValidationPayload
import io.novafoundation.nova.feature_swap_impl.domain.validation.utils.SharedQuoteValidationRetriever
class SwapEnoughLiquidityValidation(
private val sharedQuoteValidationRetriever: SharedQuoteValidationRetriever
) : SwapValidation {
override suspend fun validate(value: SwapValidationPayload): ValidationStatus<SwapValidationFailure> {
val quoteResult = sharedQuoteValidationRetriever.retrieveQuote(value)
return validOrError(quoteResult.isSuccess) {
NotEnoughLiquidity
}
}
}
@@ -0,0 +1,62 @@
package io.novafoundation.nova.feature_swap_impl.domain.validation.validations
import io.novafoundation.nova.common.validation.ValidationStatus
import io.novafoundation.nova.common.validation.valid
import io.novafoundation.nova.common.validation.validOrError
import io.novafoundation.nova.feature_account_api.data.model.amountByExecutingAccount
import io.novafoundation.nova.feature_swap_impl.domain.validation.SwapValidation
import io.novafoundation.nova.feature_swap_impl.domain.validation.SwapValidationFailure
import io.novafoundation.nova.feature_swap_impl.domain.validation.SwapValidationFailure.NotEnoughFunds
import io.novafoundation.nova.feature_swap_impl.domain.validation.SwapValidationPayload
import io.novafoundation.nova.feature_swap_impl.domain.validation.SwapValidationSystemBuilder
import io.novafoundation.nova.feature_wallet_api.domain.model.amountFromPlanks
import io.novafoundation.nova.feature_wallet_api.domain.validation.InsufficientBalanceToStayAboveEDError
import io.novafoundation.nova.feature_wallet_api.domain.validation.context.AssetsValidationContext
import io.novafoundation.nova.feature_wallet_api.domain.validation.context.getExistentialDeposit
import io.novafoundation.nova.runtime.ext.isUtilityAsset
/**
* Checks that operation can pass submission checks on node side
* In particular, it checks that there is enough native asset to pay fee and remain above ED
* This only applies when fee is paid in native asset
*/
class SwapEnoughNativeAssetBalanceToPayFeeConsideringEDValidation(
private val assetsValidationContext: AssetsValidationContext,
) : SwapValidation {
override suspend fun validate(value: SwapValidationPayload): ValidationStatus<SwapValidationFailure> {
val initialSubmissionFee = value.fee.initialSubmissionFee
val initialSubmissionFeeChainAsset = initialSubmissionFee.asset
if (!initialSubmissionFeeChainAsset.isUtilityAsset) return valid()
val initialSubmissionFeeAsset = assetsValidationContext.getAsset(initialSubmissionFeeChainAsset)
val existentialDeposit = assetsValidationContext.getExistentialDeposit(initialSubmissionFeeChainAsset)
val availableBalance = initialSubmissionFeeAsset.balanceCountedTowardsEDInPlanks
val fee = initialSubmissionFee.amountByExecutingAccount
return validOrError(availableBalance - fee >= existentialDeposit) {
val minRequiredBalance = existentialDeposit + fee
NotEnoughFunds.ToPayFeeAndStayAboveED(
asset = initialSubmissionFeeChainAsset,
errorModel = InsufficientBalanceToStayAboveEDError.ErrorModel(
minRequiredBalance = initialSubmissionFeeChainAsset.amountFromPlanks(minRequiredBalance),
availableBalance = initialSubmissionFeeChainAsset.amountFromPlanks(availableBalance),
balanceToAdd = initialSubmissionFeeChainAsset.amountFromPlanks(minRequiredBalance - availableBalance)
)
)
}
}
}
fun SwapValidationSystemBuilder.sufficientNativeBalanceToPayFeeConsideringED(
assetsValidationContext: AssetsValidationContext
) {
validate(
SwapEnoughNativeAssetBalanceToPayFeeConsideringEDValidation(
assetsValidationContext = assetsValidationContext
)
)
}
@@ -0,0 +1,46 @@
package io.novafoundation.nova.feature_swap_impl.domain.validation.validations
import io.novafoundation.nova.common.validation.ValidationStatus
import io.novafoundation.nova.common.validation.valid
import io.novafoundation.nova.common.validation.validOrError
import io.novafoundation.nova.feature_swap_api.domain.model.SwapFee
import io.novafoundation.nova.feature_swap_api.domain.model.amountOutMin
import io.novafoundation.nova.feature_swap_impl.domain.validation.SwapValidation
import io.novafoundation.nova.feature_swap_impl.domain.validation.SwapValidationFailure
import io.novafoundation.nova.feature_swap_impl.domain.validation.SwapValidationPayload
import io.novafoundation.nova.feature_swap_impl.domain.validation.SwapValidationSystemBuilder
import io.novafoundation.nova.feature_wallet_api.domain.validation.context.AssetsValidationContext
class SwapIntermediateReceivesMeetEDValidation(private val assetsValidationContext: AssetsValidationContext) : SwapValidation {
override suspend fun validate(value: SwapValidationPayload): ValidationStatus<SwapValidationFailure> {
value.fee.segments
.dropLast(1) // Last segment destination is verified in a separate validation
.onEach { swapSegment ->
val status = checkSegmentDestinationMeetsEd(swapSegment)
if (status is ValidationStatus.NotValid) return status
}
return valid()
}
private suspend fun checkSegmentDestinationMeetsEd(segment: SwapFee.SwapSegment): ValidationStatus<SwapValidationFailure>? {
val amountOutMin = segment.operation.estimatedSwapLimit.amountOutMin
val assetOut = segment.operation.assetOut
val existentialDeposit = assetsValidationContext.getExistentialDeposit(assetOut)
val outAssetBalance = assetsValidationContext.getAsset(assetOut)
return validOrError(outAssetBalance.balanceCountedTowardsEDInPlanks + amountOutMin >= existentialDeposit) {
SwapValidationFailure.IntermediateAmountOutIsTooLowToStayAboveED(
asset = outAssetBalance.token.configuration,
existentialDeposit = existentialDeposit,
amount = amountOutMin
)
}
}
}
fun SwapValidationSystemBuilder.intermediateReceivesMeetEDValidation(
assetsValidationContext: AssetsValidationContext
) = validate(SwapIntermediateReceivesMeetEDValidation(assetsValidationContext))
@@ -0,0 +1,49 @@
package io.novafoundation.nova.feature_swap_impl.domain.validation.validations
import io.novafoundation.nova.common.validation.ValidationStatus
import io.novafoundation.nova.common.validation.valid
import io.novafoundation.nova.common.validation.validOrError
import io.novafoundation.nova.feature_swap_impl.domain.validation.SwapValidation
import io.novafoundation.nova.feature_swap_impl.domain.validation.SwapValidationFailure
import io.novafoundation.nova.feature_swap_impl.domain.validation.SwapValidationPayload
import io.novafoundation.nova.feature_swap_impl.domain.validation.SwapValidationSystemBuilder
import io.novafoundation.nova.feature_wallet_api.domain.validation.context.AssetsValidationContext
import io.novasama.substrate_sdk_android.hash.isPositive
/**
* Checks that spending assetIn to swap for assetOut wont dust account and result in assetOut being lost
*/
class SwapKeepsNecessaryAmountOfAssetIn(
private val assetsValidationContext: AssetsValidationContext,
) : SwapValidation {
override suspend fun validate(value: SwapValidationPayload): ValidationStatus<SwapValidationFailure> {
val chainAssetIn = value.amountIn.chainAsset
val chainAssetOut = value.amountOut.chainAsset
val amount = value.amountIn.amount
val minimumCountedTowardsEd = value.fee.additionalMaxAmountDeduction.fromCountedTowardsEd
if (minimumCountedTowardsEd.isPositive()) {
val assetIn = assetsValidationContext.getAsset(chainAssetIn)
val fee = value.fee.totalFeeAmount(chainAssetIn)
val assetInStaysAboveEd = assetIn.balanceCountedTowardsEDInPlanks - amount - fee >= minimumCountedTowardsEd
return validOrError(assetInStaysAboveEd) {
SwapValidationFailure.InsufficientBalance.BalanceNotConsiderInsufficientReceiveAsset(
assetIn = chainAssetIn,
assetOut = chainAssetOut,
existentialDeposit = minimumCountedTowardsEd
)
}
}
return valid()
}
}
fun SwapValidationSystemBuilder.sufficientBalanceConsideringNonSufficientAssetsValidation(assetsValidationContext: AssetsValidationContext) = validate(
SwapKeepsNecessaryAmountOfAssetIn(assetsValidationContext)
)
@@ -0,0 +1,22 @@
package io.novafoundation.nova.feature_swap_impl.domain.validation.validations
import io.novafoundation.nova.common.validation.ValidationStatus
import io.novafoundation.nova.common.validation.valid
import io.novafoundation.nova.common.validation.validationError
import io.novafoundation.nova.feature_swap_impl.domain.swap.PriceImpactThresholds
import io.novafoundation.nova.feature_swap_impl.domain.validation.SwapValidation
import io.novafoundation.nova.feature_swap_impl.domain.validation.SwapValidationFailure
import io.novafoundation.nova.feature_swap_impl.domain.validation.SwapValidationPayload
class SwapPriceImpactValidation(
private val priceImpactThresholds: PriceImpactThresholds
) : SwapValidation {
override suspend fun validate(value: SwapValidationPayload): ValidationStatus<SwapValidationFailure> {
if (value.swapQuote.priceImpact > priceImpactThresholds.mediumPriceImpact) {
return SwapValidationFailure.HighPriceImpact(value.swapQuote.priceImpact).validationError()
}
return valid()
}
}
@@ -0,0 +1,40 @@
package io.novafoundation.nova.feature_swap_impl.domain.validation.validations
import io.novafoundation.nova.common.validation.ValidationStatus
import io.novafoundation.nova.common.validation.validOrError
import io.novafoundation.nova.feature_swap_api.domain.model.SwapLimit
import io.novafoundation.nova.feature_swap_api.domain.model.swapRate
import io.novafoundation.nova.feature_swap_core_api.data.paths.model.quote
import io.novafoundation.nova.feature_swap_impl.domain.validation.SwapValidation
import io.novafoundation.nova.feature_swap_impl.domain.validation.SwapValidationFailure
import io.novafoundation.nova.feature_swap_impl.domain.validation.SwapValidationFailure.NewRateExceededSlippage
import io.novafoundation.nova.feature_swap_impl.domain.validation.SwapValidationPayload
import io.novafoundation.nova.feature_swap_impl.domain.validation.estimatedSwapLimit
import io.novafoundation.nova.feature_swap_impl.domain.validation.utils.SharedQuoteValidationRetriever
import io.novafoundation.nova.feature_wallet_api.data.network.blockhain.types.Balance
class SwapRateChangesValidation(
private val quoteValidationRetriever: SharedQuoteValidationRetriever,
) : SwapValidation {
override suspend fun validate(value: SwapValidationPayload): ValidationStatus<SwapValidationFailure> {
val newQuote = quoteValidationRetriever.retrieveQuote(value).getOrThrow()
val swapLimit = value.estimatedSwapLimit()
return validOrError(swapLimit.isBalanceInSwapLimits(newQuote.quotedPath.quote)) {
NewRateExceededSlippage(
assetIn = value.amountIn.chainAsset,
assetOut = value.amountOut.chainAsset,
selectedRate = value.swapQuote.swapRate(),
newRate = newQuote.swapRate()
)
}
}
}
private fun SwapLimit.isBalanceInSwapLimits(quotedBalance: Balance): Boolean {
return when (this) {
is SwapLimit.SpecifiedIn -> quotedBalance >= amountOutMin
is SwapLimit.SpecifiedOut -> quotedBalance <= amountInMax
}
}
@@ -0,0 +1,25 @@
package io.novafoundation.nova.feature_swap_impl.domain.validation.validations
import io.novafoundation.nova.common.validation.ValidationStatus
import io.novafoundation.nova.common.validation.valid
import io.novafoundation.nova.common.validation.validationError
import io.novafoundation.nova.feature_swap_api.domain.swap.SwapService
import io.novafoundation.nova.feature_swap_impl.domain.validation.SwapValidation
import io.novafoundation.nova.feature_swap_impl.domain.validation.SwapValidationFailure
import io.novafoundation.nova.feature_swap_impl.domain.validation.SwapValidationFailure.InvalidSlippage
import io.novafoundation.nova.feature_swap_impl.domain.validation.SwapValidationPayload
class SwapSlippageRangeValidation(
private val swapService: SwapService
) : SwapValidation {
override suspend fun validate(value: SwapValidationPayload): ValidationStatus<SwapValidationFailure> {
val slippageConfig = swapService.defaultSlippageConfig(value.amountIn.chainAsset.chainId)
if (value.slippage !in slippageConfig.minAvailableSlippage..slippageConfig.maxAvailableSlippage) {
return InvalidSlippage(slippageConfig.minAvailableSlippage, slippageConfig.maxAvailableSlippage).validationError()
}
return valid()
}
}
@@ -0,0 +1,36 @@
package io.novafoundation.nova.feature_swap_impl.domain.validation.validations
import io.novafoundation.nova.common.validation.ValidationStatus
import io.novafoundation.nova.common.validation.validOrError
import io.novafoundation.nova.feature_swap_impl.domain.validation.SwapValidation
import io.novafoundation.nova.feature_swap_impl.domain.validation.SwapValidationFailure
import io.novafoundation.nova.feature_swap_impl.domain.validation.SwapValidationPayload
import io.novafoundation.nova.feature_swap_impl.domain.validation.SwapValidationSystemBuilder
import io.novafoundation.nova.feature_wallet_api.domain.validation.context.AssetsValidationContext
import io.novafoundation.nova.feature_wallet_api.domain.validation.context.getExistentialDeposit
class SwapSufficientAmountOutToStayAboveEDValidation(
private val assetsValidationContext: AssetsValidationContext,
) : SwapValidation {
override suspend fun validate(value: SwapValidationPayload): ValidationStatus<SwapValidationFailure> {
val (chainAssetOut, amountOut) = value.amountOut
val assetOut = assetsValidationContext.getAsset(chainAssetOut)
val existentialDeposit = assetsValidationContext.getExistentialDeposit(assetOut.token.configuration)
val remainingAmountStaysAboveED = assetOut.balanceCountedTowardsEDInPlanks + amountOut >= existentialDeposit
return validOrError(remainingAmountStaysAboveED) {
SwapValidationFailure.AmountOutIsTooLowToStayAboveED(
asset = assetOut.token.configuration,
amountInPlanks = amountOut,
existentialDeposit = existentialDeposit
)
}
}
}
fun SwapValidationSystemBuilder.sufficientAmountOutToStayAboveEDValidation(assetsValidationContext: AssetsValidationContext) = validate(
SwapSufficientAmountOutToStayAboveEDValidation(assetsValidationContext)
)
@@ -0,0 +1,28 @@
package io.novafoundation.nova.feature_swap_impl.presentation
import io.novafoundation.nova.common.navigation.ReturnableRouter
import io.novafoundation.nova.feature_swap_api.presentation.model.SwapSettingsPayload
import io.novafoundation.nova.feature_wallet_api.presentation.model.AssetPayload
interface SwapRouter : ReturnableRouter {
fun openSwapConfirmation()
fun openSwapRoute()
fun openSwapFee()
fun openSwapExecution()
fun selectAssetIn(selectedAsset: AssetPayload?)
fun selectAssetOut(selectedAsset: AssetPayload?)
fun openSwapOptions()
fun openRetrySwap(payload: SwapSettingsPayload)
fun openBalanceDetails(assetPayload: AssetPayload)
fun openMain()
}
@@ -0,0 +1,45 @@
package io.novafoundation.nova.feature_swap_impl.presentation.common
import io.novafoundation.nova.common.resources.ResourceManager
import io.novafoundation.nova.common.utils.Fraction
import io.novafoundation.nova.common.utils.colorSpan
import io.novafoundation.nova.common.utils.formatting.formatPercents
import io.novafoundation.nova.common.utils.toSpannable
import io.novafoundation.nova.feature_swap_impl.R
import io.novafoundation.nova.feature_swap_impl.domain.swap.PriceImpactThresholds
interface PriceImpactFormatter {
fun format(priceImpact: Fraction): CharSequence?
fun formatWithBrackets(priceImpact: Fraction): CharSequence?
}
class RealPriceImpactFormatter(
private val priceImpactThresholds: PriceImpactThresholds,
private val resourceManager: ResourceManager
) : PriceImpactFormatter {
private val thresholdsToColors = listOf(
priceImpactThresholds.highPriceImpact to R.color.text_negative,
priceImpactThresholds.mediumPriceImpact to R.color.text_warning,
priceImpactThresholds.lowPriceImpact to R.color.text_secondary,
)
override fun format(priceImpact: Fraction): CharSequence? {
val color = getColor(priceImpact) ?: return null
return priceImpact.formatPercents().toSpannable(colorSpan(resourceManager.getColor(color)))
}
override fun formatWithBrackets(priceImpact: Fraction): CharSequence? {
val color = getColor(priceImpact) ?: return null
val formattedImpact = "(${priceImpact.formatPercents()})"
return formattedImpact.toSpannable(colorSpan(resourceManager.getColor(color)))
}
private fun getColor(priceImpact: Fraction): Int? {
return thresholdsToColors.firstOrNull { priceImpact > it.first }
?.second
}
}
@@ -0,0 +1,51 @@
package io.novafoundation.nova.feature_swap_impl.presentation.common
import io.novafoundation.nova.common.resources.ResourceManager
import io.novafoundation.nova.common.utils.Fraction
import io.novafoundation.nova.feature_swap_api.domain.model.SlippageConfig
import io.novafoundation.nova.feature_swap_impl.R
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.combine
class SlippageAlertMixinFactory(
private val resourceManager: ResourceManager
) {
fun create(slippageConfig: Flow<SlippageConfig>, slippageFlow: Flow<Fraction?>): SlippageAlertMixin {
return RealSlippageAlertMixin(
resourceManager,
slippageConfig,
slippageFlow
)
}
}
interface SlippageAlertMixin {
val slippageAlertMessage: Flow<String?>
}
class RealSlippageAlertMixin(
val resourceManager: ResourceManager,
slippageConfig: Flow<SlippageConfig>,
slippageFlow: Flow<Fraction?>
) : SlippageAlertMixin {
override val slippageAlertMessage: Flow<String?> = combine(slippageConfig, slippageFlow) { slippageConfig, slippage ->
when {
slippage == null -> null
slippage !in slippageConfig.minAvailableSlippage..slippageConfig.maxAvailableSlippage -> null
slippage > slippageConfig.bigSlippage -> {
resourceManager.getString(R.string.swap_slippage_warning_too_big)
}
slippage < slippageConfig.smallSlippage -> {
resourceManager.getString(R.string.swap_slippage_warning_too_small)
}
else -> null
}
}
}
@@ -0,0 +1,16 @@
package io.novafoundation.nova.feature_swap_impl.presentation.common
import io.novafoundation.nova.feature_swap_api.presentation.formatters.SwapRateFormatter
import io.novafoundation.nova.feature_wallet_api.presentation.formatters.formatTokenAmount
import io.novafoundation.nova.runtime.multiNetwork.chain.model.Chain
import java.math.BigDecimal
class RealSwapRateFormatter : SwapRateFormatter {
override fun format(rate: BigDecimal, assetIn: Chain.Asset, assetOut: Chain.Asset): String {
val assetInUnitFormatted = BigDecimal.ONE.formatTokenAmount(assetIn)
val rateAmountFormatted = rate.formatTokenAmount(assetOut)
return "$assetInUnitFormatted$rateAmountFormatted"
}
}
@@ -0,0 +1,89 @@
package io.novafoundation.nova.feature_swap_impl.presentation.common.details
import io.novafoundation.nova.common.presentation.AssetIconProvider
import io.novafoundation.nova.common.resources.ResourceManager
import io.novafoundation.nova.common.utils.Fraction
import io.novafoundation.nova.common.utils.formatting.formatPercents
import io.novafoundation.nova.feature_account_api.data.mappers.mapChainToUi
import io.novafoundation.nova.feature_account_api.presenatation.chain.getAssetIconOrFallback
import io.novafoundation.nova.feature_swap_api.domain.model.SwapQuote
import io.novafoundation.nova.feature_swap_api.domain.model.swapRate
import io.novafoundation.nova.feature_swap_api.domain.model.totalTime
import io.novafoundation.nova.feature_swap_api.presentation.formatters.SwapRateFormatter
import io.novafoundation.nova.feature_swap_api.presentation.view.SwapAssetView
import io.novafoundation.nova.feature_swap_api.presentation.view.SwapAssetsView
import io.novafoundation.nova.feature_swap_impl.presentation.common.PriceImpactFormatter
import io.novafoundation.nova.feature_swap_impl.presentation.common.details.model.SwapConfirmationDetailsModel
import io.novafoundation.nova.feature_swap_impl.presentation.common.route.SwapRouteFormatter
import io.novafoundation.nova.feature_wallet_api.domain.interfaces.TokenRepository
import io.novafoundation.nova.feature_wallet_api.presentation.formatters.amount.AmountFormatter
import io.novafoundation.nova.feature_wallet_api.presentation.formatters.amount.formatAmountToAmountModel
import io.novafoundation.nova.feature_wallet_api.presentation.formatters.amount.model.AmountConfig
import io.novafoundation.nova.feature_wallet_api.presentation.model.AmountModel
import io.novafoundation.nova.runtime.multiNetwork.ChainRegistry
import io.novafoundation.nova.runtime.multiNetwork.chain.model.Chain
import java.math.BigDecimal
import java.math.BigInteger
interface SwapConfirmationDetailsFormatter {
suspend fun format(quote: SwapQuote, slippage: Fraction): SwapConfirmationDetailsModel
}
class RealSwapConfirmationDetailsFormatter(
private val chainRegistry: ChainRegistry,
private val assetIconProvider: AssetIconProvider,
private val tokenRepository: TokenRepository,
private val swapRouteFormatter: SwapRouteFormatter,
private val swapRateFormatter: SwapRateFormatter,
private val priceImpactFormatter: PriceImpactFormatter,
private val resourceManager: ResourceManager,
private val amountFormatter: AmountFormatter
) : SwapConfirmationDetailsFormatter {
override suspend fun format(quote: SwapQuote, slippage: Fraction): SwapConfirmationDetailsModel {
val assetIn = quote.assetIn
val assetOut = quote.assetOut
val chainIn = chainRegistry.getChain(assetIn.chainId)
val chainOut = chainRegistry.getChain(assetOut.chainId)
return SwapConfirmationDetailsModel(
assets = SwapAssetsView.Model(
assetIn = formatAssetDetails(chainIn, assetIn, quote.planksIn),
assetOut = formatAssetDetails(chainOut, assetOut, quote.planksOut)
),
rate = formatRate(quote.swapRate(), assetIn, assetOut),
priceDifference = formatPriceDifference(quote.priceImpact),
slippage = slippage.formatPercents(),
swapRouteModel = swapRouteFormatter.formatSwapRoute(quote),
estimatedExecutionTime = resourceManager.formatDuration(quote.executionEstimate.totalTime(), estimated = true)
)
}
private suspend fun formatAssetDetails(
chain: Chain,
chainAsset: Chain.Asset,
amountInPlanks: BigInteger
): SwapAssetView.Model {
val amount = formatAmount(chainAsset, amountInPlanks)
return SwapAssetView.Model(
assetIcon = assetIconProvider.getAssetIconOrFallback(chainAsset),
amount = amount,
chainUi = mapChainToUi(chain),
)
}
private fun formatRate(rate: BigDecimal, assetIn: Chain.Asset, assetOut: Chain.Asset): String {
return swapRateFormatter.format(rate, assetIn, assetOut)
}
private fun formatPriceDifference(priceDifference: Fraction): CharSequence? {
return priceImpactFormatter.format(priceDifference)
}
private suspend fun formatAmount(chainAsset: Chain.Asset, amount: BigInteger): AmountModel {
val token = tokenRepository.getToken(chainAsset)
return amountFormatter.formatAmountToAmountModel(amount, token, AmountConfig(includeZeroFiat = false, estimatedFiat = true))
}
}
@@ -0,0 +1,13 @@
package io.novafoundation.nova.feature_swap_impl.presentation.common.details.model
import io.novafoundation.nova.feature_swap_api.presentation.view.SwapAssetsView
import io.novafoundation.nova.feature_swap_impl.presentation.common.route.SwapRouteModel
class SwapConfirmationDetailsModel(
val assets: SwapAssetsView.Model,
val rate: String,
val priceDifference: CharSequence?,
val slippage: String,
val swapRouteModel: SwapRouteModel?,
val estimatedExecutionTime: String,
)
@@ -0,0 +1,28 @@
package io.novafoundation.nova.feature_swap_impl.presentation.common.fee
import androidx.lifecycle.viewModelScope
import io.novafoundation.nova.common.base.BaseViewModel
import io.novafoundation.nova.feature_swap_api.domain.model.SwapFee
import io.novafoundation.nova.feature_swap_impl.domain.interactor.SwapInteractor
import io.novafoundation.nova.feature_wallet_api.presentation.mixin.fee.model.FeeDisplay
import io.novafoundation.nova.feature_wallet_api.presentation.mixin.fee.v2.FeeLoaderMixinV2
import io.novafoundation.nova.feature_wallet_api.presentation.mixin.fee.v2.asFeeContextFromChain
import io.novafoundation.nova.runtime.multiNetwork.chain.model.Chain
import kotlinx.coroutines.flow.Flow
typealias SwapFeeLoaderMixin = FeeLoaderMixinV2.Presentation<SwapFee, FeeDisplay>
context(BaseViewModel)
fun FeeLoaderMixinV2.Factory.createForSwap(
chainAssetIn: Flow<Chain.Asset>,
interactor: SwapInteractor,
configuration: FeeLoaderMixinV2.Configuration<SwapFee, FeeDisplay> = FeeLoaderMixinV2.Configuration()
): SwapFeeLoaderMixin {
return create(
scope = viewModelScope,
feeContextFlow = chainAssetIn.asFeeContextFromChain(),
feeFormatter = SwapFeeFormatter(interactor),
feeInspector = SwapFeeInspector(),
configuration = configuration
)
}
@@ -0,0 +1,30 @@
package io.novafoundation.nova.feature_swap_impl.presentation.common.fee
import io.novafoundation.nova.feature_swap_api.domain.model.SwapFee
import io.novafoundation.nova.feature_swap_impl.domain.interactor.SwapInteractor
import io.novafoundation.nova.feature_wallet_api.presentation.formatters.formatAsCurrency
import io.novafoundation.nova.feature_wallet_api.presentation.mixin.fee.formatter.FeeFormatter
import io.novafoundation.nova.feature_wallet_api.presentation.mixin.fee.model.FeeDisplay
import io.novafoundation.nova.feature_wallet_api.presentation.mixin.fee.model.FeeStatus
class SwapFeeFormatter(
private val swapInteractor: SwapInteractor,
) : FeeFormatter<SwapFee, FeeDisplay> {
override suspend fun formatFee(
fee: SwapFee,
configuration: FeeFormatter.Configuration,
context: FeeFormatter.Context
): FeeDisplay {
val totalFiatFee = swapInteractor.calculateTotalFiatPrice(fee)
val formattedFiatFee = totalFiatFee.formatAsCurrency()
return FeeDisplay(
title = formattedFiatFee,
subtitle = null
)
}
override suspend fun createLoadingStatus(): FeeStatus.Loading {
return FeeStatus.Loading(visibleDuringProgress = true)
}
}
@@ -0,0 +1,21 @@
package io.novafoundation.nova.feature_swap_impl.presentation.common.fee
import io.novafoundation.nova.feature_account_api.data.model.amountByExecutingAccount
import io.novafoundation.nova.feature_swap_api.domain.model.SwapFee
import io.novafoundation.nova.feature_wallet_api.presentation.mixin.fee.amount.FeeInspector
import io.novafoundation.nova.feature_wallet_api.presentation.mixin.fee.amount.FeeInspector.InspectedFeeAmount
import io.novafoundation.nova.runtime.multiNetwork.chain.model.Chain
class SwapFeeInspector : FeeInspector<SwapFee> {
override fun inspectFeeAmount(fee: SwapFee): InspectedFeeAmount {
return InspectedFeeAmount(
checkedAgainstMinimumBalance = fee.initialSubmissionFee.amountByExecutingAccount,
deductedFromTransferable = fee.maxAmountDeductionFor(fee.initialSubmissionFee.asset)
)
}
override fun getSubmissionFeeAsset(fee: SwapFee): Chain.Asset {
return fee.initialSubmissionFee.asset
}
}
@@ -0,0 +1,36 @@
package io.novafoundation.nova.feature_swap_impl.presentation.common.fieldValidation
import io.novafoundation.nova.common.resources.ResourceManager
import io.novafoundation.nova.common.validation.FieldValidationResult
import io.novafoundation.nova.common.validation.FieldValidator
import io.novafoundation.nova.feature_swap_impl.R
import io.novafoundation.nova.feature_swap_impl.presentation.main.QuotingState
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.map
class LiquidityFieldValidatorFactory(
private val resourceManager: ResourceManager
) {
fun create(quotingStateFlow: Flow<QuotingState>): LiquidityFieldValidator {
return LiquidityFieldValidator(resourceManager, quotingStateFlow)
}
}
class LiquidityFieldValidator(
private val resourceManager: ResourceManager,
private val quotingStateFlow: Flow<QuotingState>
) : FieldValidator {
override fun observe(inputStream: Flow<String>): Flow<FieldValidationResult> {
return quotingStateFlow.map { quotingState ->
if (quotingState is QuotingState.Error) {
FieldValidationResult.Error(
resourceManager.getString(R.string.swap_field_validation_not_enough_liquidity)
)
} else {
FieldValidationResult.Ok
}
}
}
}
@@ -0,0 +1,49 @@
package io.novafoundation.nova.feature_swap_impl.presentation.common.fieldValidation
import io.novafoundation.nova.common.resources.ResourceManager
import io.novafoundation.nova.common.utils.Fraction
import io.novafoundation.nova.common.utils.Fraction.Companion.percents
import io.novafoundation.nova.common.utils.formatting.formatPercents
import io.novafoundation.nova.common.validation.FieldValidationResult
import io.novafoundation.nova.common.validation.MapFieldValidator
import io.novafoundation.nova.feature_swap_api.domain.model.SlippageConfig
import io.novafoundation.nova.feature_swap_impl.R
class SlippageFieldValidatorFactory(private val resourceManager: ResourceManager) {
fun create(slippageConfig: SlippageConfig): SlippageFieldValidator {
return SlippageFieldValidator(slippageConfig, resourceManager)
}
}
class SlippageFieldValidator(
private val slippageConfig: SlippageConfig,
private val resourceManager: ResourceManager
) : MapFieldValidator() {
override suspend fun validate(input: String): FieldValidationResult {
val value = input.toPercent()
return when {
input.isEmpty() -> FieldValidationResult.Ok
value == null -> FieldValidationResult.Ok
value !in slippageConfig.minAvailableSlippage..slippageConfig.maxAvailableSlippage -> {
FieldValidationResult.Error(
resourceManager.getString(
R.string.swap_slippage_error_not_in_available_range,
slippageConfig.minAvailableSlippage.formatPercents(),
slippageConfig.maxAvailableSlippage.formatPercents()
)
)
}
else -> FieldValidationResult.Ok
}
}
private fun String.toPercent(): Fraction? {
return toDoubleOrNull()?.percents
}
}
@@ -0,0 +1,67 @@
package io.novafoundation.nova.feature_swap_impl.presentation.common.fieldValidation
import io.novafoundation.nova.common.resources.ResourceManager
import io.novafoundation.nova.common.validation.FieldValidationResult
import io.novafoundation.nova.common.validation.FieldValidator
import io.novafoundation.nova.feature_swap_impl.R
import io.novafoundation.nova.feature_wallet_api.data.network.blockhain.assets.AssetSourceRegistry
import io.novafoundation.nova.feature_wallet_api.data.network.blockhain.assets.existentialDeposit
import io.novafoundation.nova.feature_wallet_api.domain.model.Asset
import io.novafoundation.nova.feature_wallet_api.domain.model.balanceCountedTowardsED
import io.novafoundation.nova.feature_wallet_api.presentation.formatters.formatTokenAmount
import io.novafoundation.nova.runtime.ext.fullId
import io.novafoundation.nova.runtime.multiNetwork.ChainRegistry
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.combine
import kotlinx.coroutines.flow.distinctUntilChangedBy
import kotlinx.coroutines.flow.map
import java.math.BigDecimal
class SwapReceiveAmountAboveEDFieldValidatorFactory(
private val resourceManager: ResourceManager,
private val chainRegistry: ChainRegistry,
private val assetSourceRegistry: AssetSourceRegistry
) {
fun create(assetFlow: Flow<Asset?>): SwapReceiveAmountAboveEDFieldValidator {
return SwapReceiveAmountAboveEDFieldValidator(resourceManager, chainRegistry, assetSourceRegistry, assetFlow)
}
}
class SwapReceiveAmountAboveEDFieldValidator(
private val resourceManager: ResourceManager,
private val chainRegistry: ChainRegistry,
private val assetSourceRegistry: AssetSourceRegistry,
private val assetFlow: Flow<Asset?>
) : FieldValidator {
override fun observe(inputStream: Flow<String>): Flow<FieldValidationResult> {
return combine(inputStream, assetWithExistentialDeposit()) { input, assetWithExistentialDeposit ->
val amount = input.toBigDecimalOrNull() ?: return@combine FieldValidationResult.Ok
val asset = assetWithExistentialDeposit?.first ?: return@combine FieldValidationResult.Ok
val existentialDeposit = assetWithExistentialDeposit.second
when {
amount >= BigDecimal.ZERO && asset.balanceCountedTowardsED() + amount < existentialDeposit -> {
val formattedExistentialDeposit = existentialDeposit.formatTokenAmount(asset.token.configuration)
FieldValidationResult.Error(
resourceManager.getString(R.string.swap_field_validation_to_low_amount_out, formattedExistentialDeposit)
)
}
else -> FieldValidationResult.Ok
}
}
}
private fun assetWithExistentialDeposit(): Flow<Pair<Asset, BigDecimal>?> {
return assetFlow
.map { asset ->
asset?.let {
val existentialDeposit = assetSourceRegistry.existentialDeposit(asset.token.configuration)
asset to existentialDeposit
}
}
.distinctUntilChangedBy { it?.first?.token?.configuration?.fullId }
}
}
@@ -0,0 +1,45 @@
package io.novafoundation.nova.feature_swap_impl.presentation.common.navigation
import android.util.Log
import io.novafoundation.nova.common.utils.invokeOnCompletion
import io.novafoundation.nova.feature_swap_api.presentation.navigation.SwapFlowScopeAggregator
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.cancel
import kotlin.coroutines.EmptyCoroutineContext
class RealSwapFlowScopeAggregator : SwapFlowScopeAggregator {
private var aggregatedScope: CoroutineScope? = null
private val scopes = mutableSetOf<CoroutineScope>()
private val lock = Any()
override fun getFlowScope(screenScope: CoroutineScope): CoroutineScope {
synchronized(lock) {
if (aggregatedScope == null) {
aggregatedScope = CoroutineScope(EmptyCoroutineContext)
}
scopes.add(screenScope)
Log.d("Swaps", "Registering new swap screen scope, total count: ${scopes.size}")
}
screenScope.invokeOnCompletion {
synchronized(lock) {
scopes -= screenScope
if (scopes.isEmpty()) {
Log.d("Swaps", "Last swap screen scope was cancelled, cancelling flow scope")
aggregatedScope!!.cancel()
aggregatedScope = null
} else {
Log.d("Swaps", "Swap screen scope was cancelled, remaining count: ${scopes.size}")
}
}
}
return aggregatedScope!!
}
}
@@ -0,0 +1,49 @@
package io.novafoundation.nova.feature_swap_impl.presentation.common.route
import io.novafoundation.nova.common.utils.graph.Path
import io.novafoundation.nova.feature_account_api.data.mappers.mapChainToUi
import io.novafoundation.nova.feature_swap_api.domain.model.SwapGraphEdge
import io.novafoundation.nova.feature_swap_api.domain.model.SwapQuote
import io.novafoundation.nova.feature_swap_core_api.data.paths.model.QuotedEdge
import io.novafoundation.nova.runtime.multiNetwork.ChainRegistry
import io.novafoundation.nova.runtime.multiNetwork.chain.model.ChainId
import io.novafoundation.nova.runtime.multiNetwork.chainsById
interface SwapRouteFormatter {
suspend fun formatSwapRoute(quote: SwapQuote): SwapRouteModel?
}
class RealSwapRouteFormatter(
private val chainRegistry: ChainRegistry
) : SwapRouteFormatter {
override suspend fun formatSwapRoute(quote: SwapQuote): SwapRouteModel? {
val routeChainIds = determinePathChains(quote.quotedPath.path) ?: return null
val allKnownChains = chainRegistry.chainsById()
val chainModels = routeChainIds.map { mapChainToUi(allKnownChains[it]!!) }
return SwapRouteModel(chainModels)
}
private fun determinePathChains(path: Path<QuotedEdge<SwapGraphEdge>>): List<ChainId>? {
if (path.isEmpty()) return null
val firstEdge = path.first().edge
val firstChain = firstEdge.from.chainId
var currentChainId = firstChain
val foundChains = mutableListOf(currentChainId)
path.forEach {
val nextChainId = it.edge.to.chainId
if (nextChainId != currentChainId) {
currentChainId = nextChainId
foundChains.add(nextChainId)
}
}
return foundChains.takeIf { foundChains.size >= 2 }
}
}
@@ -0,0 +1,10 @@
package io.novafoundation.nova.feature_swap_impl.presentation.common.route
import io.novafoundation.nova.common.domain.ExtendedLoadingState
import io.novafoundation.nova.feature_account_api.presenatation.chain.ChainUi
class SwapRouteModel(
val chains: List<ChainUi>
)
typealias SwapRouteState = ExtendedLoadingState<SwapRouteModel?>
@@ -0,0 +1,41 @@
package io.novafoundation.nova.feature_swap_impl.presentation.common.route
import android.content.Context
import android.util.AttributeSet
import io.novafoundation.nova.common.domain.isError
import io.novafoundation.nova.common.domain.isLoading
import io.novafoundation.nova.common.domain.onLoaded
import io.novafoundation.nova.common.utils.setVisible
import io.novafoundation.nova.common.view.GenericTableCellView
import io.novafoundation.nova.feature_swap_impl.R
class SwapRouteTableCellView @JvmOverloads constructor(
context: Context,
attrs: AttributeSet? = null,
defStyle: Int = 0,
defStyleRes: Int = 0,
) : GenericTableCellView<SwapRouteView>(context, attrs, defStyle, defStyleRes) {
init {
setValueView(SwapRouteView(context))
setTitle(R.string.swap_route)
}
fun setSwapRouteState(routeState: SwapRouteState) {
setVisible(!routeState.isError)
showProgress(routeState.isLoading)
routeState.onLoaded(::setSwapRouteModel)
}
fun setShowChainNames(showChainNames: Boolean) {
valueView.setShowChainNames(showChainNames)
}
fun setSwapRouteModel(model: SwapRouteModel?) {
setVisible(model != null)
model?.let(valueView::setModel)
}
}
@@ -0,0 +1,105 @@
package io.novafoundation.nova.feature_swap_impl.presentation.common.route
import android.content.Context
import android.text.TextUtils
import android.util.AttributeSet
import android.view.Gravity
import android.widget.ImageView
import android.widget.LinearLayout
import android.widget.LinearLayout.LayoutParams.WRAP_CONTENT
import android.widget.TextView
import androidx.core.view.updateMargins
import coil.ImageLoader
import io.novafoundation.nova.common.di.FeatureUtils
import io.novafoundation.nova.common.utils.dp
import io.novafoundation.nova.common.utils.setImageTintRes
import io.novafoundation.nova.common.utils.setTextColorRes
import io.novafoundation.nova.common.utils.useAttributes
import io.novafoundation.nova.feature_account_api.presenatation.chain.ChainUi
import io.novafoundation.nova.feature_account_api.presenatation.chain.loadChainIcon
import io.novafoundation.nova.feature_swap_impl.R
private const val SHOW_CHAIN_NAMES_DEFAULT = false
class SwapRouteView @JvmOverloads constructor(
context: Context,
attrs: AttributeSet? = null,
defStyleAttr: Int = 0,
) : LinearLayout(context, attrs, defStyleAttr) {
private val imageLoader: ImageLoader by lazy(LazyThreadSafetyMode.NONE) {
FeatureUtils.getCommonApi(context).imageLoader()
}
private var shouldShowChainNames: Boolean = SHOW_CHAIN_NAMES_DEFAULT
init {
orientation = HORIZONTAL
gravity = Gravity.CENTER_VERTICAL
attrs?.let(::applyAttrs)
}
fun setModel(model: SwapRouteModel) {
removeAllViews()
addViewsFor(model)
}
fun setShowChainNames(showChainNames: Boolean) {
shouldShowChainNames = showChainNames
}
private fun addViewsFor(model: SwapRouteModel) {
model.chains.forEachIndexed { index, chainUi ->
val hasNext = index < model.chains.size - 1
addChainIcon(chainUi)
if (shouldShowChainNames) {
addChainName(chainUi)
}
if (hasNext) {
addArrow()
}
}
}
private fun addChainIcon(chainUi: ChainUi) {
ImageView(context).apply {
layoutParams = LayoutParams(16.dp, 16.dp)
loadChainIcon(chainUi.icon, imageLoader)
}.also(::addView)
}
private fun addChainName(chainUi: ChainUi) {
TextView(context).apply {
layoutParams = LayoutParams(WRAP_CONTENT, WRAP_CONTENT).apply {
updateMargins(left = 8.dp)
}
ellipsize = TextUtils.TruncateAt.END
setTextAppearance(R.style.TextAppearance_NovaFoundation_Regular_Footnote)
setTextColorRes(R.color.text_primary)
text = chainUi.name
}.also(::addView)
}
private fun addArrow() {
ImageView(context).apply {
layoutParams = LayoutParams(12.dp, 12.dp).apply {
updateMargins(left = 4.dp, right = 4.dp)
}
setImageResource(R.drawable.ic_arrow_right)
setImageTintRes(R.color.icon_secondary)
}.also(::addView)
}
private fun applyAttrs(attributeSet: AttributeSet) = context.useAttributes(attributeSet, R.styleable.SwapRouteView) {
val shouldShowChainNames = it.getBoolean(R.styleable.SwapRouteView_SwapRouteView_displayChainName, SHOW_CHAIN_NAMES_DEFAULT)
setShowChainNames(shouldShowChainNames)
}
}
@@ -0,0 +1,83 @@
package io.novafoundation.nova.feature_swap_impl.presentation.common.state
import io.novafoundation.nova.common.utils.Fraction
import io.novafoundation.nova.feature_swap_api.presentation.state.SwapSettings
import io.novafoundation.nova.feature_swap_api.presentation.state.SwapSettingsState
import io.novafoundation.nova.feature_swap_core_api.data.primitive.model.SwapDirection
import io.novafoundation.nova.feature_swap_core_api.data.primitive.model.flip
import io.novafoundation.nova.feature_wallet_api.data.network.blockhain.types.Balance
import io.novafoundation.nova.feature_wallet_api.domain.model.amountFromPlanks
import io.novafoundation.nova.feature_wallet_api.domain.model.planksFromAmount
import io.novafoundation.nova.runtime.multiNetwork.chain.model.Chain
import kotlinx.coroutines.flow.MutableStateFlow
class RealSwapSettingsState(
initialValue: SwapSettings,
) : SwapSettingsState {
override val selectedOption = MutableStateFlow(initialValue)
override suspend fun setAssetIn(asset: Chain.Asset) {
val current = selectedOption.value
val newPlanks = current.convertedAmountForNewAssetIn(asset)
val new = current.copy(assetIn = asset, amount = newPlanks)
selectedOption.value = new
}
override fun setAssetOut(asset: Chain.Asset) {
val current = selectedOption.value
val newPlanks = current.convertedAmountForNewAssetOut(asset)
selectedOption.value = selectedOption.value.copy(assetOut = asset, amount = newPlanks)
}
override fun setAmount(amount: Balance?, swapDirection: SwapDirection) {
selectedOption.value = selectedOption.value.copy(amount = amount, swapDirection = swapDirection)
}
override fun setSlippage(slippage: Fraction) {
selectedOption.value = selectedOption.value.copy(slippage = slippage)
}
override suspend fun flipAssets(): SwapSettings {
val currentSettings = selectedOption.value
val newSettings = currentSettings.copy(
assetIn = currentSettings.assetOut,
assetOut = currentSettings.assetIn,
swapDirection = currentSettings.swapDirection?.flip()
)
selectedOption.value = newSettings
return newSettings
}
override fun setSwapSettings(swapSettings: SwapSettings) {
selectedOption.value = swapSettings
}
private fun SwapSettings.convertedAmountForNewAssetIn(newAssetIn: Chain.Asset): Balance? {
val shouldConvertAsset = assetIn != null && amount != null && swapDirection == SwapDirection.SPECIFIED_IN
return if (shouldConvertAsset) {
val decimalAmount = assetIn!!.amountFromPlanks(amount!!)
newAssetIn.planksFromAmount(decimalAmount)
} else {
amount
}
}
private fun SwapSettings.convertedAmountForNewAssetOut(newAssetOut: Chain.Asset): Balance? {
val shouldConvertAsset = assetOut != null && amount != null && swapDirection == SwapDirection.SPECIFIED_OUT
return if (shouldConvertAsset) {
val decimalAmount = assetOut!!.amountFromPlanks(amount!!)
newAssetOut.planksFromAmount(decimalAmount)
} else {
amount
}
}
}
@@ -0,0 +1,26 @@
package io.novafoundation.nova.feature_swap_impl.presentation.common.state
import io.novafoundation.nova.common.data.memory.ComputationalCache
import io.novafoundation.nova.common.utils.flowOfAll
import io.novafoundation.nova.feature_swap_api.presentation.state.DEFAULT_SLIPPAGE
import io.novafoundation.nova.feature_swap_api.presentation.state.SwapSettings
import io.novafoundation.nova.feature_swap_api.presentation.state.SwapSettingsStateProvider
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.flow.Flow
fun SwapSettingsStateProvider.swapSettingsFlow(coroutineScope: CoroutineScope): Flow<SwapSettings> {
return flowOfAll {
getSwapSettingsState(coroutineScope).selectedOption
}
}
class RealSwapSettingsStateProvider(
private val computationalCache: ComputationalCache,
) : SwapSettingsStateProvider {
override suspend fun getSwapSettingsState(coroutineScope: CoroutineScope): RealSwapSettingsState {
return computationalCache.useCache("SwapSettingsState", coroutineScope) {
RealSwapSettingsState(SwapSettings(slippage = DEFAULT_SLIPPAGE))
}
}
}
@@ -0,0 +1,11 @@
package io.novafoundation.nova.feature_swap_impl.presentation.common.state
import io.novafoundation.nova.common.utils.Fraction
import io.novafoundation.nova.feature_swap_api.domain.model.SwapFee
import io.novafoundation.nova.feature_swap_api.domain.model.SwapQuote
class SwapState(
val quote: SwapQuote,
val fee: SwapFee,
val slippage: Fraction,
)
@@ -0,0 +1,43 @@
package io.novafoundation.nova.feature_swap_impl.presentation.common.state
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.filterNotNull
interface SwapStateStore {
fun setState(state: SwapState)
fun resetState()
fun getState(): SwapState?
fun stateFlow(): Flow<SwapState>
}
fun SwapStateStore.getStateOrThrow(): SwapState {
return requireNotNull(getState()) {
"Quote was not set"
}
}
class InMemorySwapStateStore() : SwapStateStore {
private var swapState = MutableStateFlow<SwapState?>(null)
override fun setState(state: SwapState) {
this.swapState.value = state
}
override fun resetState() {
swapState.value = null
}
override fun getState(): SwapState? {
return swapState.value
}
override fun stateFlow(): Flow<SwapState> {
return swapState.filterNotNull()
}
}
@@ -0,0 +1,41 @@
package io.novafoundation.nova.feature_swap_impl.presentation.common.state
import androidx.lifecycle.viewModelScope
import io.novafoundation.nova.common.base.BaseViewModel
import io.novafoundation.nova.common.data.memory.ComputationalCache
import io.novafoundation.nova.common.utils.flowOfAll
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.flow.Flow
interface SwapStateStoreProvider {
suspend fun getStore(computationScope: CoroutineScope): SwapStateStore
}
class RealSwapStateStoreProvider(
private val computationalCache: ComputationalCache
) : SwapStateStoreProvider {
companion object {
private const val CACHE_TAG = "RealSwapQuoteStoreProvider"
}
override suspend fun getStore(computationScope: CoroutineScope): SwapStateStore {
return computationalCache.useCache(CACHE_TAG, computationScope) {
InMemorySwapStateStore()
}
}
}
suspend fun SwapStateStoreProvider.getStateOrThrow(computationScope: CoroutineScope): SwapState {
return getStore(computationScope).getStateOrThrow()
}
fun SwapStateStoreProvider.stateFlow(computationScope: CoroutineScope): Flow<SwapState> {
return flowOfAll { getStore(computationScope).stateFlow() }
}
context(BaseViewModel)
suspend fun SwapStateStoreProvider.setState(state: SwapState) {
getStore(viewModelScope).setState(state)
}
@@ -0,0 +1,103 @@
package io.novafoundation.nova.feature_swap_impl.presentation.common.views
import android.content.Context
import android.util.AttributeSet
import android.widget.EditText
import androidx.constraintlayout.widget.ConstraintLayout
import androidx.core.view.isVisible
import coil.ImageLoader
import io.novafoundation.nova.common.di.FeatureUtils
import io.novafoundation.nova.common.utils.WithContextExtensions
import io.novafoundation.nova.common.utils.images.Icon
import io.novafoundation.nova.common.utils.images.setIconOrMakeGone
import io.novafoundation.nova.common.utils.inflater
import io.novafoundation.nova.common.utils.setImageTint
import io.novafoundation.nova.common.validation.FieldValidationResult
import io.novafoundation.nova.common.validation.getReasonOrNull
import io.novafoundation.nova.common.view.shape.getInputBackground
import io.novafoundation.nova.common.view.shape.getInputBackgroundError
import io.novafoundation.nova.feature_account_api.presenatation.chain.setTokenIcon
import io.novafoundation.nova.feature_swap_impl.R
import io.novafoundation.nova.feature_swap_impl.databinding.ViewSwapAmountInputBinding
import io.novafoundation.nova.feature_swap_impl.presentation.main.input.SwapAmountInputMixin.SwapInputAssetModel
import io.novafoundation.nova.feature_wallet_api.presentation.mixin.amountChooser.AmountInputView
class SwapAmountInputView @JvmOverloads constructor(
context: Context,
attrs: AttributeSet? = null,
defStyleAttr: Int = 0
) : ConstraintLayout(context, attrs, defStyleAttr),
WithContextExtensions by WithContextExtensions(context),
AmountInputView {
private val binder = ViewSwapAmountInputBinding.inflate(inflater(), this)
override val amountInput: EditText
get() = binder.swapAmountInputField
private val imageLoader: ImageLoader by lazy(LazyThreadSafetyMode.NONE) {
FeatureUtils.getCommonApi(context).imageLoader()
}
init {
binder.swapAmountInputContainer.background = context.getInputBackground()
binder.swapAmountInputContainer.setAddStatesFromChildren(true)
}
fun setSelectTokenClickListener(listener: OnClickListener) {
binder.swapAmountInputContainer.setOnClickListener(listener)
}
fun setModel(model: SwapInputAssetModel) {
setAssetIcon(model.assetIcon)
setTitle(model.title)
setSubtitle(model.subtitleIcon, model.subtitle)
binder.swapAmountInputFiat.isVisible = model.showInput
amountInput.isVisible = model.showInput
}
override fun setFiatAmount(fiat: CharSequence?) {
binder.swapAmountInputFiat.text = fiat
}
override fun setError(errorState: FieldValidationResult) {
binder.swapAmountInputError.text = errorState.getReasonOrNull()
setErrorEnabled(errorState is FieldValidationResult.Error)
}
private fun setTitle(title: CharSequence) {
binder.swapAmountInputToken.text = title
}
private fun setSubtitle(icon: Icon?, subtitle: CharSequence) {
binder.swapAmountInputSubtitleImage.setIconOrMakeGone(icon, imageLoader)
binder.swapAmountInputSubtitle.text = subtitle
}
private fun setAssetIcon(icon: SwapInputAssetModel.SwapAssetIcon) {
return when (icon) {
is SwapInputAssetModel.SwapAssetIcon.Chosen -> {
binder.swapAmountInputImage.setImageTint(null)
binder.swapAmountInputImage.setTokenIcon(icon.assetIcon, imageLoader)
binder.swapAmountInputImage.setBackgroundResource(R.drawable.bg_token_container)
}
SwapInputAssetModel.SwapAssetIcon.NotChosen -> {
binder.swapAmountInputImage.setImageTint(context.getColor(R.color.icon_accent))
binder.swapAmountInputImage.setImageResource(R.drawable.ic_add)
binder.swapAmountInputImage.setBackgroundResource(R.drawable.ic_swap_asset_default_background)
}
}
}
fun setErrorEnabled(enabled: Boolean) {
binder.swapAmountInputError.isVisible = enabled
if (enabled) {
amountInput.setTextColor(context.getColor(R.color.text_negative))
binder.swapAmountInputContainer.background = context.getInputBackgroundError()
} else {
amountInput.setTextColor(context.getColor(R.color.text_primary))
binder.swapAmountInputContainer.background = context.getInputBackground()
}
}
}
@@ -0,0 +1,68 @@
package io.novafoundation.nova.feature_swap_impl.presentation.confirmation
import io.novafoundation.nova.common.base.BaseFragment
import io.novafoundation.nova.common.di.FeatureUtils
import io.novafoundation.nova.common.mixin.impl.observeValidations
import io.novafoundation.nova.common.view.bottomSheet.description.observeDescription
import io.novafoundation.nova.common.view.setMessageOrHide
import io.novafoundation.nova.common.view.setProgressState
import io.novafoundation.nova.common.view.showValueOrHide
import io.novafoundation.nova.feature_account_api.view.showWallet
import io.novafoundation.nova.feature_account_api.presenatation.actions.setupExternalActions
import io.novafoundation.nova.feature_account_api.view.showAddress
import io.novafoundation.nova.feature_swap_api.di.SwapFeatureApi
import io.novafoundation.nova.feature_swap_impl.databinding.FragmentSwapConfirmationBinding
import io.novafoundation.nova.feature_swap_impl.di.SwapFeatureComponent
import io.novafoundation.nova.feature_wallet_api.presentation.mixin.fee.v2.setupFeeLoading
class SwapConfirmationFragment : BaseFragment<SwapConfirmationViewModel, FragmentSwapConfirmationBinding>() {
override fun createBinding() = FragmentSwapConfirmationBinding.inflate(layoutInflater)
override fun initViews() {
binder.swapConfirmationToolbar.setHomeButtonListener { viewModel.backClicked() }
binder.swapConfirmationButton.prepareForProgress(this)
binder.swapConfirmationRate.setOnClickListener { viewModel.rateClicked() }
binder.swapConfirmationPriceDifference.setOnClickListener { viewModel.priceDifferenceClicked() }
binder.swapConfirmationSlippage.setOnClickListener { viewModel.slippageClicked() }
binder.swapConfirmationNetworkFee.setOnClickListener { viewModel.networkFeeClicked() }
binder.swapConfirmationAccount.setOnClickListener { viewModel.accountClicked() }
binder.swapConfirmationButton.setOnClickListener { viewModel.confirmButtonClicked() }
binder.swapConfirmationRoute.setOnClickListener { viewModel.routeClicked() }
}
override fun inject() {
FeatureUtils.getFeature<SwapFeatureComponent>(
requireContext(),
SwapFeatureApi::class.java
)
.swapConfirmation()
.create(this)
.inject(this)
}
override fun subscribe(viewModel: SwapConfirmationViewModel) {
observeValidations(viewModel)
setupExternalActions(viewModel)
observeDescription(viewModel)
viewModel.feeMixin.setupFeeLoading(binder.swapConfirmationNetworkFee)
viewModel.swapDetails.observe {
binder.swapConfirmationAssets.setModel(it.assets)
binder.swapConfirmationRate.showValue(it.rate)
binder.swapConfirmationPriceDifference.showValueOrHide(it.priceDifference)
binder.swapConfirmationSlippage.showValue(it.slippage)
binder.swapConfirmationRoute.setSwapRouteModel(it.swapRouteModel)
binder.swapConfirmationExecutionTime.showValue(it.estimatedExecutionTime)
}
viewModel.wallet.observe { binder.swapConfirmationWallet.showWallet(it) }
viewModel.addressFlow.observe { binder.swapConfirmationAccount.showAddress(it) }
viewModel.slippageAlertMixin.slippageAlertMessage.observe { binder.swapConfirmationAlert.setMessageOrHide(it) }
viewModel.validationInProgress.observe(binder.swapConfirmationButton::setProgressState)
}
}
@@ -0,0 +1,397 @@
package io.novafoundation.nova.feature_swap_impl.presentation.confirmation
import androidx.lifecycle.viewModelScope
import io.novafoundation.nova.common.address.AddressIconGenerator
import io.novafoundation.nova.common.address.AddressModel
import io.novafoundation.nova.common.base.BaseViewModel
import io.novafoundation.nova.common.mixin.api.Validatable
import io.novafoundation.nova.common.resources.ResourceManager
import io.novafoundation.nova.common.utils.combineToPair
import io.novafoundation.nova.common.utils.flowOf
import io.novafoundation.nova.common.utils.launchUnit
import io.novafoundation.nova.common.utils.singleReplaySharedFlow
import io.novafoundation.nova.common.validation.TransformedFailure
import io.novafoundation.nova.common.validation.ValidationExecutor
import io.novafoundation.nova.common.validation.ValidationFlowActions
import io.novafoundation.nova.common.validation.ValidationStatus
import io.novafoundation.nova.common.validation.progressConsumer
import io.novafoundation.nova.common.view.bottomSheet.description.DescriptionBottomSheetLauncher
import io.novafoundation.nova.feature_account_api.domain.interfaces.AccountRepository
import io.novafoundation.nova.feature_account_api.presenatation.account.icon.createAccountAddressModel
import io.novafoundation.nova.feature_account_api.presenatation.account.wallet.WalletModel
import io.novafoundation.nova.feature_account_api.presenatation.account.wallet.WalletUiUseCase
import io.novafoundation.nova.feature_account_api.presenatation.actions.ExternalActions
import io.novafoundation.nova.feature_account_api.presenatation.actions.showAddressActions
import io.novafoundation.nova.feature_account_api.presenatation.navigation.ExtrinsicNavigationWrapper
import io.novafoundation.nova.feature_swap_api.domain.model.SwapFee
import io.novafoundation.nova.feature_swap_api.domain.model.SwapOperationSubmissionException
import io.novafoundation.nova.feature_swap_api.domain.model.SwapQuote
import io.novafoundation.nova.feature_swap_api.domain.model.SwapQuoteArgs
import io.novafoundation.nova.feature_swap_api.domain.model.toExecuteArgs
import io.novafoundation.nova.feature_swap_api.presentation.navigation.SwapFlowScopeAggregator
import io.novafoundation.nova.feature_swap_api.presentation.view.bottomSheet.description.launchPriceDifferenceDescription
import io.novafoundation.nova.feature_swap_api.presentation.view.bottomSheet.description.launchSlippageDescription
import io.novafoundation.nova.feature_swap_api.presentation.view.bottomSheet.description.launchSwapRateDescription
import io.novafoundation.nova.feature_swap_core_api.data.paths.model.quotedAmount
import io.novafoundation.nova.feature_swap_core_api.data.primitive.model.SwapDirection
import io.novafoundation.nova.feature_swap_impl.R
import io.novafoundation.nova.feature_swap_impl.domain.interactor.SwapInteractor
import io.novafoundation.nova.feature_swap_impl.domain.validation.SwapValidationFailure
import io.novafoundation.nova.feature_swap_impl.domain.validation.SwapValidationPayload
import io.novafoundation.nova.feature_swap_impl.domain.validation.toSwapState
import io.novafoundation.nova.feature_swap_impl.presentation.SwapRouter
import io.novafoundation.nova.feature_swap_impl.presentation.common.SlippageAlertMixinFactory
import io.novafoundation.nova.feature_swap_impl.presentation.common.details.SwapConfirmationDetailsFormatter
import io.novafoundation.nova.feature_swap_impl.presentation.common.fee.createForSwap
import io.novafoundation.nova.feature_swap_impl.presentation.common.state.SwapStateStoreProvider
import io.novafoundation.nova.feature_swap_impl.presentation.common.state.getStateOrThrow
import io.novafoundation.nova.feature_swap_impl.presentation.common.state.setState
import io.novafoundation.nova.feature_swap_impl.presentation.main.mapSwapValidationFailureToUI
import io.novafoundation.nova.feature_wallet_api.data.network.blockhain.types.Balance
import io.novafoundation.nova.feature_wallet_api.domain.ArbitraryAssetUseCase
import io.novafoundation.nova.feature_wallet_api.domain.interfaces.TokenRepository
import io.novafoundation.nova.feature_wallet_api.presentation.mixin.amountChooser.maxAction.MaxActionProvider
import io.novafoundation.nova.feature_wallet_api.presentation.mixin.fee.v2.FeeLoaderMixinV2
import io.novafoundation.nova.feature_wallet_api.presentation.mixin.fee.v2.awaitFee
import io.novafoundation.nova.feature_wallet_api.presentation.mixin.maxAction.MaxActionProviderFactory
import io.novafoundation.nova.feature_wallet_api.presentation.mixin.maxAction.create
import io.novafoundation.nova.feature_wallet_api.presentation.model.toAssetPayload
import io.novafoundation.nova.runtime.multiNetwork.ChainRegistry
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.combine
import kotlinx.coroutines.flow.distinctUntilChanged
import kotlinx.coroutines.flow.filter
import kotlinx.coroutines.flow.first
import kotlinx.coroutines.flow.flatMapLatest
import kotlinx.coroutines.flow.launchIn
import kotlinx.coroutines.flow.map
import kotlinx.coroutines.flow.mapNotNull
import kotlinx.coroutines.flow.onEach
import kotlinx.coroutines.launch
private data class SwapConfirmationState(
val swapQuoteArgs: SwapQuoteArgs,
val swapQuote: SwapQuote,
)
enum class MaxAction {
ACTIVE,
DISABLED
}
class SwapConfirmationViewModel(
private val swapRouter: SwapRouter,
private val swapInteractor: SwapInteractor,
private val accountRepository: AccountRepository,
private val chainRegistry: ChainRegistry,
private val walletUiUseCase: WalletUiUseCase,
private val slippageAlertMixinFactory: SlippageAlertMixinFactory,
private val addressIconGenerator: AddressIconGenerator,
private val validationExecutor: ValidationExecutor,
private val tokenRepository: TokenRepository,
private val externalActions: ExternalActions.Presentation,
private val swapStateStoreProvider: SwapStateStoreProvider,
private val feeLoaderMixinFactory: FeeLoaderMixinV2.Factory,
private val descriptionBottomSheetLauncher: DescriptionBottomSheetLauncher,
private val arbitraryAssetUseCase: ArbitraryAssetUseCase,
private val maxActionProviderFactory: MaxActionProviderFactory,
private val swapConfirmationDetailsFormatter: SwapConfirmationDetailsFormatter,
private val resourceManager: ResourceManager,
private val swapFlowScopeAggregator: SwapFlowScopeAggregator,
private val extrinsicNavigationWrapper: ExtrinsicNavigationWrapper
) : BaseViewModel(),
ExternalActions by externalActions,
Validatable by validationExecutor,
DescriptionBottomSheetLauncher by descriptionBottomSheetLauncher,
ExtrinsicNavigationWrapper by extrinsicNavigationWrapper {
private val swapFlowScope = swapFlowScopeAggregator.getFlowScope(viewModelScope)
private val confirmationStateFlow = singleReplaySharedFlow<SwapConfirmationState>()
private val metaAccountFlow = accountRepository.selectedMetaAccountFlow()
.shareInBackground()
private val slippageConfigFlow = confirmationStateFlow
.mapNotNull { swapInteractor.slippageConfig(it.swapQuote.assetIn.chainId) }
.shareInBackground()
private val initialSwapState = flowOf { swapStateStoreProvider.getStateOrThrow(swapFlowScope) }
private val slippageFlow = initialSwapState.map { it.slippage }
.shareInBackground()
val slippageAlertMixin = slippageAlertMixinFactory.create(slippageConfigFlow, slippageFlow)
private val chainIn = initialSwapState.map {
chainRegistry.getChain(it.quote.assetIn.chainId)
}
.shareInBackground()
private val assetInFlow = initialSwapState.flatMapLatest {
arbitraryAssetUseCase.assetFlow(it.quote.assetIn)
}
.shareInBackground()
private val assetOutFlow = initialSwapState.flatMapLatest {
arbitraryAssetUseCase.assetFlow(it.quote.assetOut)
}
.shareInBackground()
private val maxActionFlow = MutableStateFlow(MaxAction.DISABLED)
val feeMixin = feeLoaderMixinFactory.createForSwap(
chainAssetIn = initialSwapState.map { it.quote.assetIn },
interactor = swapInteractor
)
private val maxActionProvider = createMaxActionProvider()
private val _validationInProgress = MutableStateFlow(false)
val validationInProgress = _validationInProgress
val swapDetails = confirmationStateFlow.map {
swapConfirmationDetailsFormatter.format(it.swapQuote, slippageFlow.first())
}
val wallet: Flow<WalletModel> = walletUiUseCase.selectedWalletUiFlow()
val addressFlow: Flow<AddressModel> = combine(chainIn, metaAccountFlow) { chainId, metaAccount ->
addressIconGenerator.createAccountAddressModel(chainId, metaAccount)
}
init {
initConfirmationState()
handleMaxClick()
}
fun backClicked() {
swapRouter.back()
}
fun rateClicked() {
launchSwapRateDescription()
}
fun priceDifferenceClicked() {
launchPriceDifferenceDescription()
}
fun slippageClicked() {
launchSlippageDescription()
}
fun networkFeeClicked() = setSwapStateAndThen {
swapRouter.openSwapFee()
}
fun routeClicked() = setSwapStateAfter {
swapRouter.openSwapRoute()
}
fun accountClicked() {
launch {
val chainIn = chainIn.first()
val addressModel = addressFlow.first()
externalActions.showAddressActions(addressModel.address, chainIn)
}
}
fun confirmButtonClicked() {
launch {
_validationInProgress.value = true
val validationSystem = swapInteractor.validationSystem()
val payload = getValidationPayload()
validationExecutor.requireValid(
validationSystem = validationSystem,
payload = payload,
progressConsumer = _validationInProgress.progressConsumer(),
validationFailureTransformerCustom = ::formatValidationFailure,
block = ::executeSwap
)
}
}
private fun setSwapStateAndThen(action: () -> Unit) {
launch {
updateSwapStateInStore()
action()
}
}
private fun setSwapStateAfter(action: () -> Unit) {
launch {
val store = swapStateStoreProvider.getStore(swapFlowScope)
store.resetState()
action()
updateSwapStateInStore()
}
}
private suspend fun updateSwapStateInStore() {
swapStateStoreProvider.setState(getValidationPayload().toSwapState())
}
private fun createMaxActionProvider(): MaxActionProvider {
return maxActionProviderFactory.create(
viewModelScope = viewModelScope,
assetInFlow = assetInFlow,
feeLoaderMixin = feeMixin,
)
}
private fun executeSwap(validPayload: SwapValidationPayload) = launchUnit {
if (swapInteractor.isDeepSwapAvailable()) {
swapStateStoreProvider.setState(validPayload.toSwapState())
swapRouter.openSwapExecution()
} else {
executeFirstSwapStep(validPayload.fee)
}
}
private suspend fun executeFirstSwapStep(fee: SwapFee) {
swapInteractor.submitFirstSwapStep(fee)
.onSuccess {
_validationInProgress.value = false
this.showToast(resourceManager.getString(R.string.common_transaction_submitted))
startNavigation(it.submissionHierarchy) {
val asset = assetOutFlow.first()
swapRouter.openBalanceDetails(asset.token.configuration.toAssetPayload())
}
}.onFailure {
_validationInProgress.value = false
showFirstSwapStepFailure(it)
}
}
private fun showFirstSwapStepFailure(error: Throwable) {
if (error !is SwapOperationSubmissionException) {
showError(resourceManager.getString(R.string.common_undefined_error_message))
return
}
when (error) {
is SwapOperationSubmissionException.SimulationFailed -> showError(
title = resourceManager.getString(R.string.common_dry_run_failed_title),
text = resourceManager.getText(R.string.common_dry_run_failed_message)
)
}
}
private suspend fun getValidationPayload(): SwapValidationPayload {
val confirmationState = confirmationStateFlow.first()
val swapFee = feeMixin.awaitFee()
return SwapValidationPayload(
swapQuote = confirmationState.swapQuote,
fee = swapFee,
slippage = slippageFlow.first()
)
}
private fun formatValidationFailure(
status: ValidationStatus.NotValid<SwapValidationFailure>,
actions: ValidationFlowActions<SwapValidationPayload>
): TransformedFailure {
return mapSwapValidationFailureToUI(
resourceManager,
status,
actions,
amountInSwapMaxAction = ::setMaxAmountIn,
amountOutSwapMinAction = { _, amount -> setMinAmountOut(amount) }
)
}
private fun setMaxAmountIn() {
launch {
maxActionFlow.value = MaxAction.ACTIVE
}
}
private fun setMinAmountOut(amount: Balance) = launchUnit {
maxActionFlow.value = MaxAction.DISABLED
val confirmationState = confirmationStateFlow.first()
calculateQuote(
confirmationState.swapQuoteArgs.copy(
amount = amount,
swapDirection = SwapDirection.SPECIFIED_OUT
)
)
}
private fun calculateQuote(newSwapQuoteArgs: SwapQuoteArgs) {
launch {
val confirmationState = confirmationStateFlow.first()
val swapQuote = swapInteractor.quote(newSwapQuoteArgs, swapFlowScope)
.onFailure { }
.getOrNull() ?: return@launch
feeMixin.loadFee { feePaymentCurrency ->
val executeArgs = swapQuote.toExecuteArgs(
slippage = slippageFlow.first(),
firstSegmentFees = feePaymentCurrency
)
swapInteractor.estimateFee(executeArgs)
}
val newState = confirmationState.copy(swapQuoteArgs = newSwapQuoteArgs, swapQuote = swapQuote)
confirmationStateFlow.emit(newState)
}
}
private fun initConfirmationState() {
launch {
val swapState = initialSwapState.first()
val swapQuote = swapState.quote
val assetIn = swapQuote.assetIn
val assetOut = swapQuote.assetOut
val quoteArgs = SwapQuoteArgs(
tokenIn = tokenRepository.getToken(assetIn),
tokenOut = tokenRepository.getToken(assetOut),
amount = swapQuote.quotedPath.quotedAmount,
swapDirection = swapQuote.quotedPath.direction,
)
feeMixin.setFee(swapState.fee)
val newState = SwapConfirmationState(quoteArgs, swapQuote)
confirmationStateFlow.emit(newState)
}
}
private fun handleMaxClick() {
combineToPair(maxActionFlow, maxActionProvider.maxAvailableBalance)
.filter { (maxAction, _) -> maxAction == MaxAction.ACTIVE }
.mapNotNull { it.second.actualBalance }
.distinctUntilChanged()
.onEach {
val confirmationState = confirmationStateFlow.first()
calculateQuote(
confirmationState.swapQuoteArgs.copy(
amount = it,
swapDirection = SwapDirection.SPECIFIED_IN
)
)
}
.launchIn(viewModelScope)
}
}
@@ -0,0 +1,26 @@
package io.novafoundation.nova.feature_swap_impl.presentation.confirmation.di
import androidx.fragment.app.Fragment
import dagger.BindsInstance
import dagger.Subcomponent
import io.novafoundation.nova.common.di.scope.ScreenScope
import io.novafoundation.nova.feature_swap_impl.presentation.confirmation.SwapConfirmationFragment
@Subcomponent(
modules = [
SwapConfirmationModule::class
]
)
@ScreenScope
interface SwapConfirmationComponent {
@Subcomponent.Factory
interface Factory {
fun create(
@BindsInstance fragment: Fragment,
): SwapConfirmationComponent
}
fun inject(fragment: SwapConfirmationFragment)
}
@@ -0,0 +1,89 @@
package io.novafoundation.nova.feature_swap_impl.presentation.confirmation.di
import androidx.fragment.app.Fragment
import androidx.lifecycle.ViewModel
import androidx.lifecycle.ViewModelProvider
import dagger.Module
import dagger.Provides
import dagger.multibindings.IntoMap
import io.novafoundation.nova.common.address.AddressIconGenerator
import io.novafoundation.nova.common.di.viewmodel.ViewModelKey
import io.novafoundation.nova.common.di.viewmodel.ViewModelModule
import io.novafoundation.nova.common.resources.ResourceManager
import io.novafoundation.nova.common.validation.ValidationExecutor
import io.novafoundation.nova.common.view.bottomSheet.description.DescriptionBottomSheetLauncher
import io.novafoundation.nova.feature_account_api.domain.interfaces.AccountRepository
import io.novafoundation.nova.feature_account_api.presenatation.account.wallet.WalletUiUseCase
import io.novafoundation.nova.feature_account_api.presenatation.actions.ExternalActions
import io.novafoundation.nova.feature_account_api.presenatation.navigation.ExtrinsicNavigationWrapper
import io.novafoundation.nova.feature_swap_api.presentation.navigation.SwapFlowScopeAggregator
import io.novafoundation.nova.feature_swap_impl.domain.interactor.SwapInteractor
import io.novafoundation.nova.feature_swap_impl.presentation.SwapRouter
import io.novafoundation.nova.feature_swap_impl.presentation.common.SlippageAlertMixinFactory
import io.novafoundation.nova.feature_swap_impl.presentation.common.details.SwapConfirmationDetailsFormatter
import io.novafoundation.nova.feature_wallet_api.presentation.mixin.maxAction.MaxActionProviderFactory
import io.novafoundation.nova.feature_swap_impl.presentation.common.state.SwapStateStoreProvider
import io.novafoundation.nova.feature_swap_impl.presentation.confirmation.SwapConfirmationViewModel
import io.novafoundation.nova.feature_wallet_api.domain.ArbitraryAssetUseCase
import io.novafoundation.nova.feature_wallet_api.domain.interfaces.TokenRepository
import io.novafoundation.nova.feature_wallet_api.presentation.mixin.fee.v2.FeeLoaderMixinV2
import io.novafoundation.nova.runtime.multiNetwork.ChainRegistry
@Module(includes = [ViewModelModule::class])
class SwapConfirmationModule {
@Provides
@IntoMap
@ViewModelKey(SwapConfirmationViewModel::class)
fun provideViewModel(
swapRouter: SwapRouter,
swapInteractor: SwapInteractor,
accountRepository: AccountRepository,
chainRegistry: ChainRegistry,
walletUiUseCase: WalletUiUseCase,
slippageAlertMixinFactory: SlippageAlertMixinFactory,
addressIconGenerator: AddressIconGenerator,
validationExecutor: ValidationExecutor,
tokenRepository: TokenRepository,
externalActions: ExternalActions.Presentation,
feeLoaderMixinFactory: FeeLoaderMixinV2.Factory,
descriptionBottomSheetLauncher: DescriptionBottomSheetLauncher,
assetUseCase: ArbitraryAssetUseCase,
maxActionProviderFactory: MaxActionProviderFactory,
swapStateStoreProvider: SwapStateStoreProvider,
confirmationDetailsFormatter: SwapConfirmationDetailsFormatter,
resourceManager: ResourceManager,
swapFlowScopeAggregator: SwapFlowScopeAggregator,
extrinsicNavigationWrapper: ExtrinsicNavigationWrapper
): ViewModel {
return SwapConfirmationViewModel(
swapRouter = swapRouter,
swapInteractor = swapInteractor,
accountRepository = accountRepository,
chainRegistry = chainRegistry,
walletUiUseCase = walletUiUseCase,
slippageAlertMixinFactory = slippageAlertMixinFactory,
addressIconGenerator = addressIconGenerator,
validationExecutor = validationExecutor,
tokenRepository = tokenRepository,
externalActions = externalActions,
swapStateStoreProvider = swapStateStoreProvider,
feeLoaderMixinFactory = feeLoaderMixinFactory,
descriptionBottomSheetLauncher = descriptionBottomSheetLauncher,
arbitraryAssetUseCase = assetUseCase,
maxActionProviderFactory = maxActionProviderFactory,
swapConfirmationDetailsFormatter = confirmationDetailsFormatter,
resourceManager = resourceManager,
swapFlowScopeAggregator = swapFlowScopeAggregator,
extrinsicNavigationWrapper = extrinsicNavigationWrapper
)
}
@Provides
fun provideViewModelCreator(
fragment: Fragment,
viewModelFactory: ViewModelProvider.Factory
): SwapConfirmationViewModel {
return ViewModelProvider(fragment, viewModelFactory).get(SwapConfirmationViewModel::class.java)
}
}
@@ -0,0 +1,221 @@
package io.novafoundation.nova.feature_swap_impl.presentation.execution
import android.content.Context
import android.os.CountDownTimer
import android.util.AttributeSet
import android.view.Gravity
import android.view.View
import android.view.animation.Animation
import android.view.animation.AnimationUtils
import android.view.animation.BaseInterpolator
import android.view.animation.DecelerateInterpolator
import android.view.animation.OvershootInterpolator
import android.view.animation.RotateAnimation
import android.widget.TextSwitcher
import android.widget.TextView
import androidx.constraintlayout.widget.ConstraintLayout
import io.novafoundation.nova.common.utils.WithContextExtensions
import io.novafoundation.nova.common.utils.inflater
import io.novafoundation.nova.common.utils.makeGone
import io.novafoundation.nova.common.utils.makeVisible
import io.novafoundation.nova.common.utils.setTextColorRes
import io.novafoundation.nova.feature_swap_impl.R
import io.novafoundation.nova.feature_swap_impl.databinding.ViewExecutionTimerBinding
import kotlin.math.cos
import kotlin.time.Duration
import kotlin.time.Duration.Companion.milliseconds
private const val SECOND_MILLIS = 1000L
private const val HIDE_SCALE = 0.7f
class ExecutionTimerView @JvmOverloads constructor(
context: Context,
attrs: AttributeSet? = null,
defStyleAttr: Int = 0
) : ConstraintLayout(context, attrs, defStyleAttr), WithContextExtensions by WithContextExtensions(context) {
sealed interface State {
object Success : State
object Error : State
class CountdownTimer(val duration: Duration) : State
}
private val binder = ViewExecutionTimerBinding.inflate(inflater(), this)
private var currentState: State? = null
private val slideTopInAnimation = AnimationUtils.loadAnimation(context, R.anim.fade_slide_bottom_in)
private val slideTopOutAnimation = AnimationUtils.loadAnimation(context, R.anim.fade_slide_bottom_out)
private var currentTimer: CountDownTimer? = null
init {
setupTimerSwitcher()
}
fun setState(state: State) {
currentState = state
currentTimer?.cancel()
when (state) {
State.Success -> {
hideTimerWithAnimation()
binder.executionResult.setImageResource(R.drawable.ic_execution_result_success)
binder.executionResult.fadeInWithScale()
}
State.Error -> {
hideTimerWithAnimation()
binder.executionResult.setImageResource(R.drawable.ic_execution_result_error)
binder.executionResult.fadeInWithScale()
}
is State.CountdownTimer -> {
binder.executionResult.fadeOutWithScale()
showTimerWithAnimation()
binder.executionProgress.runInfinityRotationAnimation()
// We add delay to match progress animation perfectly
// Text should be switched in the middle of a progress animation with small offset
val middleOfAnimation = SECOND_MILLIS / 2
val switchAnimationOffset = slideTopOutAnimation.duration / 2
val delay = middleOfAnimation - switchAnimationOffset
currentTimer = CountdownSwitcherTimer(binder.executionTimeSwitcher, state.duration)
runTimerWithDelay(delay, currentTimer!!)
}
}
}
private fun hideTimerWithAnimation() {
binder.executionProgress.fadeOut()
binder.executionTimeSwitcher.fadeOutWithScale()
binder.executionTimeSeconds.fadeOutWithScale()
}
private fun showTimerWithAnimation() {
binder.executionProgress.fadeIn()
binder.executionTimeSwitcher.fadeInWithScale()
binder.executionTimeSeconds.fadeInWithScale()
}
private fun runTimerWithDelay(delay: Long, timer: CountDownTimer) {
postDelayed({ timer.start() }, delay)
}
private fun setupTimerSwitcher() {
binder.executionTimeSwitcher.setFactory {
val textView = TextView(context, null, 0, R.style.TextAppearance_NovaFoundation_Bold_Title3)
textView.setGravity(Gravity.CENTER)
textView.setTextColorRes(R.color.text_primary)
textView.includeFontPadding = false
textView
}
binder.executionTimeSwitcher.inAnimation = slideTopInAnimation
binder.executionTimeSwitcher.outAnimation = slideTopOutAnimation
}
private fun View.runInfinityRotationAnimation() {
val anim = RotateAnimation(
0f,
-360f,
Animation.RELATIVE_TO_SELF,
0.5f,
Animation.RELATIVE_TO_SELF,
0.5f
)
anim.duration = SECOND_MILLIS
anim.repeatCount = Animation.INFINITE
anim.interpolator = StartSpeedAccelerateDecelerateInterpolator()
startAnimation(anim)
}
private fun View.fadeOut() {
animate()
.alpha(0f)
.setDuration(400)
.withEndAction { makeGone() }
.setInterpolator(DecelerateInterpolator())
.start()
}
private fun View.fadeIn() {
alpha = 0f
makeVisible()
animate()
.alpha(1f)
.setDuration(400)
.setInterpolator(DecelerateInterpolator())
.start()
}
private fun View.fadeOutWithScale() {
animate()
.alpha(0f)
.scaleX(HIDE_SCALE)
.scaleY(HIDE_SCALE)
.setDuration(400)
.withEndAction { makeGone() }
.setInterpolator(DecelerateInterpolator())
.start()
}
private fun View.fadeInWithScale() {
scaleX = HIDE_SCALE
scaleY = HIDE_SCALE
alpha = 0f
makeVisible()
animate()
.alpha(1f)
.scaleX(1f)
.scaleY(1f)
.setDuration(400)
.setInterpolator(OvershootInterpolator())
.start()
}
}
private class StartSpeedAccelerateDecelerateInterpolator : BaseInterpolator() {
override fun getInterpolation(input: Float): Float {
val speed = 0.085 // A constant
val result = input + cos((input * 2 * Math.PI) + Math.PI / 2) * speed
return result.toFloat()
}
}
private class CountdownSwitcherTimer(val switcher: TextSwitcher, duration: Duration) :
CountDownTimer(
duration.inWholeMilliseconds + SECOND_MILLIS, // Add a seconds to show max value to user
SECOND_MILLIS
) {
init {
switcher.setText(duration.inWholeSeconds.toString())
}
override fun onTick(millisUntilFinished: Long) {
val duration = millisUntilFinished.milliseconds
val seconds = duration.inWholeSeconds.toString()
if (shouldPlayAnimation(seconds)) {
switcher.setText(seconds)
}
}
override fun onFinish() {
// Nothing to do
}
private fun shouldPlayAnimation(seconds: String): Boolean {
val currentTextView = switcher.currentView as? TextView ?: return true
return currentTextView.text != seconds
}
}
@@ -0,0 +1,152 @@
package io.novafoundation.nova.feature_swap_impl.presentation.execution
import android.text.TextUtils
import android.view.Gravity
import android.view.animation.AnimationUtils
import android.widget.TextSwitcher
import android.widget.TextView
import io.novafoundation.nova.common.base.BaseFragment
import io.novafoundation.nova.common.di.FeatureUtils
import io.novafoundation.nova.common.utils.makeGone
import io.novafoundation.nova.common.utils.makeVisible
import io.novafoundation.nova.common.utils.setCurrentText
import io.novafoundation.nova.common.utils.setText
import io.novafoundation.nova.common.utils.setTextColorRes
import io.novafoundation.nova.common.view.bottomSheet.description.observeDescription
import io.novafoundation.nova.common.view.shape.getBlockDrawable
import io.novafoundation.nova.common.view.shape.getRoundedCornerDrawable
import io.novafoundation.nova.common.view.showValueOrHide
import io.novafoundation.nova.feature_swap_api.di.SwapFeatureApi
import io.novafoundation.nova.feature_swap_impl.R
import io.novafoundation.nova.feature_swap_impl.databinding.FragmentSwapExecutionBinding
import io.novafoundation.nova.feature_swap_impl.di.SwapFeatureComponent
import io.novafoundation.nova.feature_swap_impl.presentation.execution.model.SwapProgressModel
import io.novafoundation.nova.feature_wallet_api.presentation.mixin.fee.v2.setupFeeLoading
class SwapExecutionFragment : BaseFragment<SwapExecutionViewModel, FragmentSwapExecutionBinding>() {
override fun createBinding() = FragmentSwapExecutionBinding.inflate(layoutInflater)
override fun initViews() {
binder.swapExecutionRate.setOnClickListener { viewModel.rateClicked() }
binder.swapExecutionPriceDifference.setOnClickListener { viewModel.priceDifferenceClicked() }
binder.swapExecutionSlippage.setOnClickListener { viewModel.slippageClicked() }
binder.swapExecutionNetworkFee.setOnClickListener { viewModel.networkFeeClicked() }
binder.swapExecutionRoute.setOnClickListener { viewModel.routeClicked() }
binder.swapExecutionDetails.collapseImmediate()
onBackPressed { /* suppress back presses */ }
binder.swapExecutionTitleSwitcher.applyTitleFactory()
binder.swapExecutionSubtitleSwitcher.applySubtitleFactory()
binder.swapExecutionTitleSwitcher.applyAnimators()
binder.swapExecutionSubtitleSwitcher.applyAnimators()
binder.swapExecutionToolbar.setHomeButtonVisibility(false)
}
override fun inject() {
FeatureUtils.getFeature<SwapFeatureComponent>(requireContext(), SwapFeatureApi::class.java)
.swapExecution()
.create(this)
.inject(this)
}
override fun subscribe(viewModel: SwapExecutionViewModel) {
observeDescription(viewModel)
viewModel.swapProgressModel.observe(::setSwapProgress)
viewModel.feeMixin.setupFeeLoading(binder.swapExecutionNetworkFee)
viewModel.confirmationDetailsFlow.observe {
binder.swapExecutionAssets.setModel(it.assets)
binder.swapExecutionRate.showValue(it.rate)
binder.swapExecutionPriceDifference.showValueOrHide(it.priceDifference)
binder.swapExecutionSlippage.showValue(it.slippage)
binder.swapExecutionRoute.setSwapRouteModel(it.swapRouteModel)
}
}
private fun setSwapProgress(model: SwapProgressModel) {
when (model) {
is SwapProgressModel.Completed -> setSwapCompleted(model)
is SwapProgressModel.Failed -> setSwapFailed(model)
is SwapProgressModel.InProgress -> setSwapInProgress(model)
}
}
private fun setSwapCompleted(model: SwapProgressModel.Completed) {
binder.swapExecutionTimer.setState(ExecutionTimerView.State.Success)
binder.swapExecutionTitleSwitcher.setText(getString(R.string.common_completed), colorRes = R.color.text_positive)
binder.swapExecutionSubtitleSwitcher.setText(model.at, colorRes = R.color.text_secondary)
binder.swapExecutionStepLabel.text = model.operationsLabel
binder.swapExecutionStepLabel.setTextColorRes(R.color.text_secondary)
binder.swapExecutionStepShimmer.hideShimmer()
binder.swapExecutionStepContainer.background = requireContext().getBlockDrawable()
binder.swapExecutionActionButton.makeVisible()
binder.swapExecutionActionButton.setText(R.string.common_done)
binder.swapExecutionActionButton.setOnClickListener { viewModel.doneClicked() }
}
private fun setSwapFailed(model: SwapProgressModel.Failed) {
binder.swapExecutionTimer.setState(ExecutionTimerView.State.Error)
binder.swapExecutionTitleSwitcher.setText(getString(R.string.common_failed), colorRes = R.color.text_negative)
binder.swapExecutionSubtitleSwitcher.setText(model.at, colorRes = R.color.text_secondary)
binder.swapExecutionStepLabel.text = model.reason
binder.swapExecutionStepLabel.setTextColorRes(R.color.text_primary)
binder.swapExecutionStepShimmer.hideShimmer()
binder.swapExecutionStepContainer.background = requireContext().getRoundedCornerDrawable(R.color.error_block_background)
binder.swapExecutionActionButton.makeVisible()
binder.swapExecutionActionButton.setText(R.string.common_try_again)
binder.swapExecutionActionButton.setOnClickListener { viewModel.retryClicked() }
}
private fun setSwapInProgress(model: SwapProgressModel.InProgress) {
binder.swapExecutionTimer.setState(ExecutionTimerView.State.CountdownTimer(model.remainingTime))
binder.swapExecutionTitleSwitcher.setCurrentText(getString(R.string.common_do_not_close_app), colorRes = R.color.text_primary)
binder.swapExecutionSubtitleSwitcher.setCurrentText(model.stepDescription, colorRes = R.color.button_text_accent)
binder.swapExecutionStepLabel.text = model.operationsLabel
binder.swapExecutionStepLabel.setTextColorRes(R.color.text_secondary)
binder.swapExecutionStepShimmer.showShimmer(true)
binder.swapExecutionStepContainer.background = requireContext().getBlockDrawable()
binder.swapExecutionActionButton.makeGone()
}
private fun TextSwitcher.applyTitleFactory() {
setFactory {
val textView = TextView(context, null, 0, R.style.TextAppearance_NovaFoundation_Bold_Title1)
textView.setGravity(Gravity.CENTER)
textView
}
}
private fun TextSwitcher.applySubtitleFactory() {
setFactory {
val textView = TextView(context, null, 0, R.style.TextAppearance_NovaFoundation_SemiBold_Body)
textView.setGravity(Gravity.CENTER)
textView.setSingleLine()
textView.ellipsize = TextUtils.TruncateAt.END
textView
}
}
private fun TextSwitcher.applyAnimators() {
inAnimation = AnimationUtils.loadAnimation(context, R.anim.fade_scale_in)
outAnimation = AnimationUtils.loadAnimation(context, R.anim.fade_scale_out)
}
}
@@ -0,0 +1,275 @@
package io.novafoundation.nova.feature_swap_impl.presentation.execution
import androidx.lifecycle.viewModelScope
import io.novafoundation.nova.common.base.BaseViewModel
import io.novafoundation.nova.common.resources.ResourceManager
import io.novafoundation.nova.common.utils.flowOf
import io.novafoundation.nova.common.utils.formatting.format
import io.novafoundation.nova.common.utils.inBackground
import io.novafoundation.nova.common.utils.launchUnit
import io.novafoundation.nova.common.utils.singleReplaySharedFlow
import io.novafoundation.nova.common.view.bottomSheet.description.DescriptionBottomSheetLauncher
import io.novafoundation.nova.feature_account_api.presenatation.navigation.ExtrinsicNavigationWrapper
import io.novafoundation.nova.feature_swap_api.domain.model.AtomicOperationDisplayData
import io.novafoundation.nova.feature_swap_api.domain.model.SwapOperationSubmissionException
import io.novafoundation.nova.feature_swap_api.domain.model.SwapProgress
import io.novafoundation.nova.feature_swap_api.domain.model.SwapProgressStep
import io.novafoundation.nova.feature_swap_api.domain.model.quotedAmount
import io.novafoundation.nova.feature_swap_api.domain.model.remainingTimeWhenExecuting
import io.novafoundation.nova.feature_swap_api.domain.model.swapDirection
import io.novafoundation.nova.feature_swap_api.presentation.model.SwapSettingsPayload
import io.novafoundation.nova.feature_swap_api.presentation.model.toParcel
import io.novafoundation.nova.feature_swap_api.presentation.navigation.SwapFlowScopeAggregator
import io.novafoundation.nova.feature_swap_api.presentation.view.bottomSheet.description.launchPriceDifferenceDescription
import io.novafoundation.nova.feature_swap_api.presentation.view.bottomSheet.description.launchSlippageDescription
import io.novafoundation.nova.feature_swap_api.presentation.view.bottomSheet.description.launchSwapRateDescription
import io.novafoundation.nova.feature_swap_impl.R
import io.novafoundation.nova.feature_swap_impl.domain.interactor.SwapInteractor
import io.novafoundation.nova.feature_swap_impl.presentation.SwapRouter
import io.novafoundation.nova.feature_swap_impl.presentation.common.details.SwapConfirmationDetailsFormatter
import io.novafoundation.nova.feature_swap_impl.presentation.common.fee.createForSwap
import io.novafoundation.nova.feature_swap_impl.presentation.common.state.SwapState
import io.novafoundation.nova.feature_swap_impl.presentation.common.state.SwapStateStoreProvider
import io.novafoundation.nova.feature_swap_impl.presentation.common.state.getStateOrThrow
import io.novafoundation.nova.feature_swap_impl.presentation.execution.model.SwapProgressModel
import io.novafoundation.nova.feature_wallet_api.presentation.mixin.fee.v2.FeeLoaderMixinV2
import io.novafoundation.nova.feature_wallet_api.presentation.model.toAssetPayload
import io.novafoundation.nova.runtime.ext.fullId
import io.novafoundation.nova.runtime.multiNetwork.ChainRegistry
import io.novafoundation.nova.runtime.multiNetwork.asset
import io.novafoundation.nova.runtime.multiNetwork.chainWithAsset
import kotlinx.coroutines.flow.collect
import kotlinx.coroutines.flow.combine
import kotlinx.coroutines.flow.first
import kotlinx.coroutines.flow.map
import kotlinx.coroutines.flow.onEach
class SwapExecutionViewModel(
private val swapStateStoreProvider: SwapStateStoreProvider,
private val swapInteractor: SwapInteractor,
private val resourceManager: ResourceManager,
private val router: SwapRouter,
private val chainRegistry: ChainRegistry,
private val feeLoaderMixinFactory: FeeLoaderMixinV2.Factory,
private val confirmationDetailsFormatter: SwapConfirmationDetailsFormatter,
private val descriptionBottomSheetLauncher: DescriptionBottomSheetLauncher,
private val swapFlowScopeAggregator: SwapFlowScopeAggregator,
private val extrinsicNavigationWrapper: ExtrinsicNavigationWrapper,
) : BaseViewModel(),
DescriptionBottomSheetLauncher by descriptionBottomSheetLauncher,
ExtrinsicNavigationWrapper by extrinsicNavigationWrapper {
private val swapFlowScope = swapFlowScopeAggregator.getFlowScope(viewModelScope)
private val swapStateFlow = flowOf { swapStateStoreProvider.getStateOrThrow(swapFlowScope) }
private val swapProgressFlow = singleReplaySharedFlow<SwapProgress>()
private val totalSteps = swapStateFlow.map { it.fee.segments.size }
val swapProgressModel = combine(swapStateFlow, swapProgressFlow) { swapState, swapProgress ->
swapProgress.toUi(swapState)
}.shareInBackground()
val feeMixin = feeLoaderMixinFactory.createForSwap(
chainAssetIn = swapStateFlow.map { it.quote.assetIn },
interactor = swapInteractor
)
val confirmationDetailsFlow = swapStateFlow.map {
confirmationDetailsFormatter.format(it.quote, it.slippage)
}.shareInBackground()
init {
setFee()
executeSwap()
}
fun rateClicked() {
launchSwapRateDescription()
}
fun priceDifferenceClicked() {
launchPriceDifferenceDescription()
}
fun slippageClicked() {
launchSlippageDescription()
}
fun networkFeeClicked() {
router.openSwapFee()
}
fun routeClicked() {
router.openSwapRoute()
}
fun retryClicked() = launchUnit {
val swapFailure = swapProgressFlow.first() as? SwapProgress.Failure ?: return@launchUnit
val failedStep = swapFailure.attemptedStep
val retrySwapPayload = retrySwapPayload(failedStep)
router.openRetrySwap(retrySwapPayload)
}
fun doneClicked() = launchUnit {
val assetOut = swapStateFlow.first().quote.assetOut.fullId.toAssetPayload()
router.openBalanceDetails(assetOut)
}
private fun retrySwapPayload(failedStep: SwapProgressStep): SwapSettingsPayload {
val failedOperation = failedStep.operation
return SwapSettingsPayload.RepeatOperation(
assetIn = failedOperation.assetIn.toAssetPayload(),
assetOut = failedOperation.assetOut.toAssetPayload(),
amount = failedOperation.estimatedSwapLimit.quotedAmount,
direction = failedOperation.estimatedSwapLimit.swapDirection.toParcel(),
)
}
private fun setFee() = launchUnit {
feeMixin.setFee(swapStateFlow.first().fee)
}
private fun executeSwap() = launchUnit {
val fee = swapStateFlow.first().fee
swapInteractor.executeSwap(fee)
.onEach(swapProgressFlow::emit)
.inBackground()
.collect()
}
private suspend fun SwapProgress.toUi(swapState: SwapState): SwapProgressModel {
return when (this) {
is SwapProgress.Done -> createCompletedStatus()
is SwapProgress.Failure -> toUi()
is SwapProgress.StepStarted -> toUi(swapState)
}
}
private suspend fun SwapProgress.StepStarted.toUi(swapState: SwapState): SwapProgressModel.InProgress {
val stepDescription = step.displayData.createInProgressLabel()
val remainingExecutionTime = swapState.quote.executionEstimate.remainingTimeWhenExecuting(step.index)
return SwapProgressModel.InProgress(
stepDescription = stepDescription,
remainingTime = remainingExecutionTime,
operationsLabel = swapState.inProgressLabelForStep(step.index)
)
}
private fun SwapState.inProgressLabelForStep(stepIndex: Int): String {
val totalSteps = fee.segments.size
val currentStepNumber = stepIndex + 1
return resourceManager.getString(R.string.swap_execution_operations_progress, currentStepNumber, totalSteps)
}
private suspend fun createCompletedStatus(): SwapProgressModel.Completed {
val totalSteps = totalSteps.first()
val stepsLabel = resourceManager.getQuantityString(R.plurals.swap_execution_operations_completed, totalSteps, totalSteps)
return SwapProgressModel.Completed(at = createAtLabel(), operationsLabel = stepsLabel)
}
private fun createAtLabel(): String {
val currentTime = System.currentTimeMillis()
return resourceManager.formatDateTime(currentTime)
}
private suspend fun SwapProgress.Failure.toUi(): SwapProgressModel.Failed {
return SwapProgressModel.Failed(
reason = createSwapFailureMessage(),
at = createAtLabel()
)
}
private suspend fun SwapProgress.Failure.createSwapFailureMessage(): String {
val failedStepNumber = attemptedStep.index + 1
val label = attemptedStep.displayData.createErrorLabel()
val genericErrorMessage = resourceManager.getString(
R.string.swap_execution_failure,
failedStepNumber.format(),
label
)
val errorFormatted = formatThrowable()
return if (errorFormatted != null) {
"$genericErrorMessage: $errorFormatted"
} else {
genericErrorMessage
}
}
private fun SwapProgress.Failure.formatThrowable(): String? {
if (error !is SwapOperationSubmissionException) return null
// For some reason smart-cast does not work here
return when (error as SwapOperationSubmissionException) {
is SwapOperationSubmissionException.SimulationFailed -> resourceManager.getString(R.string.swap_dry_run_failed_inline_message)
}
}
private suspend fun AtomicOperationDisplayData.createErrorLabel(): String {
return when (this) {
is AtomicOperationDisplayData.Swap -> {
val fromAsset = chainRegistry.asset(from.chainAssetId)
val toAsset = chainRegistry.asset(to.chainAssetId)
val on = chainRegistry.getChain(fromAsset.chainId)
resourceManager.getString(
R.string.swap_execution_failure_swap_label,
fromAsset.symbol.value,
toAsset.symbol.value,
on.name
)
}
is AtomicOperationDisplayData.Transfer -> {
val (chainFrom, assetFrom) = chainRegistry.chainWithAsset(from)
val chainTo = chainRegistry.getChain(to.chainId)
resourceManager.getString(
R.string.swap_execution_failure_transfer_label,
assetFrom.symbol.value,
chainFrom.name,
chainTo.name,
)
}
}
}
private suspend fun AtomicOperationDisplayData.createInProgressLabel(): String {
return when (this) {
is AtomicOperationDisplayData.Swap -> {
val fromAsset = chainRegistry.asset(from.chainAssetId)
val toAsset = chainRegistry.asset(to.chainAssetId)
val on = chainRegistry.getChain(fromAsset.chainId)
resourceManager.getString(
R.string.swap_execution_progress_swap_label,
fromAsset.symbol.value,
toAsset.symbol.value,
on.name
)
}
is AtomicOperationDisplayData.Transfer -> {
val (_, assetFrom) = chainRegistry.chainWithAsset(from)
val chainTo = chainRegistry.getChain(to.chainId)
resourceManager.getString(
R.string.swap_execution_progress_transfer_label,
assetFrom.symbol.value,
chainTo.name
)
}
}
}
}
@@ -0,0 +1,26 @@
package io.novafoundation.nova.feature_swap_impl.presentation.execution.di
import androidx.fragment.app.Fragment
import dagger.BindsInstance
import dagger.Subcomponent
import io.novafoundation.nova.common.di.scope.ScreenScope
import io.novafoundation.nova.feature_swap_impl.presentation.execution.SwapExecutionFragment
@Subcomponent(
modules = [
SwapExecutionModule::class
]
)
@ScreenScope
interface SwapExecutionComponent {
@Subcomponent.Factory
interface Factory {
fun create(
@BindsInstance fragment: Fragment,
): SwapExecutionComponent
}
fun inject(fragment: SwapExecutionFragment)
}
@@ -0,0 +1,62 @@
package io.novafoundation.nova.feature_swap_impl.presentation.execution.di
import androidx.fragment.app.Fragment
import androidx.lifecycle.ViewModel
import androidx.lifecycle.ViewModelProvider
import dagger.Module
import dagger.Provides
import dagger.multibindings.IntoMap
import io.novafoundation.nova.common.di.viewmodel.ViewModelKey
import io.novafoundation.nova.common.di.viewmodel.ViewModelModule
import io.novafoundation.nova.common.resources.ResourceManager
import io.novafoundation.nova.common.view.bottomSheet.description.DescriptionBottomSheetLauncher
import io.novafoundation.nova.feature_account_api.presenatation.navigation.ExtrinsicNavigationWrapper
import io.novafoundation.nova.feature_swap_api.presentation.navigation.SwapFlowScopeAggregator
import io.novafoundation.nova.feature_swap_impl.domain.interactor.SwapInteractor
import io.novafoundation.nova.feature_swap_impl.presentation.SwapRouter
import io.novafoundation.nova.feature_swap_impl.presentation.common.details.SwapConfirmationDetailsFormatter
import io.novafoundation.nova.feature_swap_impl.presentation.common.state.SwapStateStoreProvider
import io.novafoundation.nova.feature_swap_impl.presentation.execution.SwapExecutionViewModel
import io.novafoundation.nova.feature_wallet_api.presentation.mixin.fee.v2.FeeLoaderMixinV2
import io.novafoundation.nova.runtime.multiNetwork.ChainRegistry
@Module(includes = [ViewModelModule::class])
class SwapExecutionModule {
@Provides
@IntoMap
@ViewModelKey(SwapExecutionViewModel::class)
fun provideViewModel(
swapStateStoreProvider: SwapStateStoreProvider,
swapInteractor: SwapInteractor,
resourceManager: ResourceManager,
router: SwapRouter,
chainRegistry: ChainRegistry,
confirmationDetailsFormatter: SwapConfirmationDetailsFormatter,
feeLoaderMixinFactory: FeeLoaderMixinV2.Factory,
descriptionBottomSheetLauncher: DescriptionBottomSheetLauncher,
swapFlowScopeAggregator: SwapFlowScopeAggregator,
extrinsicNavigationWrapper: ExtrinsicNavigationWrapper,
): ViewModel {
return SwapExecutionViewModel(
swapStateStoreProvider = swapStateStoreProvider,
swapInteractor = swapInteractor,
resourceManager = resourceManager,
router = router,
chainRegistry = chainRegistry,
confirmationDetailsFormatter = confirmationDetailsFormatter,
feeLoaderMixinFactory = feeLoaderMixinFactory,
descriptionBottomSheetLauncher = descriptionBottomSheetLauncher,
swapFlowScopeAggregator = swapFlowScopeAggregator,
extrinsicNavigationWrapper = extrinsicNavigationWrapper
)
}
@Provides
fun provideViewModelCreator(
fragment: Fragment,
viewModelFactory: ViewModelProvider.Factory
): SwapExecutionViewModel {
return ViewModelProvider(fragment, viewModelFactory).get(SwapExecutionViewModel::class.java)
}
}
@@ -0,0 +1,12 @@
package io.novafoundation.nova.feature_swap_impl.presentation.execution.model
import io.novafoundation.nova.feature_swap_api.presentation.view.SwapAssetsView
import io.novafoundation.nova.feature_swap_impl.presentation.common.route.SwapRouteModel
class SwapExecutionDetailsModel(
val assets: SwapAssetsView.Model,
val rate: String,
val priceDifference: CharSequence?,
val slippage: String,
val swapRouteModel: SwapRouteModel?,
)
@@ -0,0 +1,16 @@
package io.novafoundation.nova.feature_swap_impl.presentation.execution.model
import kotlin.time.Duration
sealed class SwapProgressModel {
class InProgress(
val stepDescription: String,
val remainingTime: Duration,
val operationsLabel: String
) : SwapProgressModel()
class Completed(val at: String, val operationsLabel: String) : SwapProgressModel()
class Failed(val reason: String, val at: String) : SwapProgressModel()
}
@@ -0,0 +1,105 @@
package io.novafoundation.nova.feature_swap_impl.presentation.fee
import android.view.View
import android.widget.LinearLayout.LayoutParams
import android.widget.LinearLayout.LayoutParams.MATCH_PARENT
import android.widget.LinearLayout.LayoutParams.WRAP_CONTENT
import androidx.core.view.updateMargins
import io.novafoundation.nova.common.base.BaseBottomSheetFragment
import io.novafoundation.nova.common.di.FeatureUtils
import io.novafoundation.nova.common.domain.onLoaded
import io.novafoundation.nova.common.view.TableView
import io.novafoundation.nova.feature_swap_api.di.SwapFeatureApi
import io.novafoundation.nova.feature_swap_impl.databinding.FragmentSwapFeeBinding
import io.novafoundation.nova.feature_swap_impl.di.SwapFeatureComponent
import io.novafoundation.nova.feature_swap_impl.presentation.common.route.SwapRouteTableCellView
import io.novafoundation.nova.feature_swap_impl.presentation.fee.model.SwapSegmentFeeModel
import io.novafoundation.nova.feature_swap_impl.presentation.fee.model.SwapSegmentFeeModel.FeeOperationModel
import io.novafoundation.nova.feature_swap_impl.presentation.fee.model.SwapSegmentFeeModel.SwapComponentFeeModel
import io.novafoundation.nova.feature_wallet_api.presentation.view.FeeView
class SwapFeeFragment : BaseBottomSheetFragment<SwapFeeViewModel, FragmentSwapFeeBinding>() {
override fun createBinding() = FragmentSwapFeeBinding.inflate(layoutInflater)
override fun initViews() {}
override fun inject() {
FeatureUtils.getFeature<SwapFeatureComponent>(
requireContext(),
SwapFeatureApi::class.java
)
.swapFee()
.create(this)
.inject(this)
}
override fun subscribe(viewModel: SwapFeeViewModel) {
viewModel.swapFeeSegments.observe { feeState ->
feeState.onLoaded(::showFeeSegments)
}
viewModel.totalFee.observe(binder.swapFeeTotal::setText)
}
private fun showFeeSegments(feeSegments: List<SwapSegmentFeeModel>) {
binder.swapFeeContent.removeAllViews()
return feeSegments.forEachIndexed { index, swapSegmentFeeModel ->
showFeeSegment(
feeSegment = swapSegmentFeeModel,
isFirst = index == 0,
isLast = index == feeSegments.size - 1
)
}
}
private fun showFeeSegment(
feeSegment: SwapSegmentFeeModel,
isFirst: Boolean,
isLast: Boolean
) {
val segmentTable = TableView(requireContext()).apply {
layoutParams = LayoutParams(MATCH_PARENT, WRAP_CONTENT).apply {
updateMargins(
top = if (isFirst) 8.dp else 12.dp,
bottom = if (isLast) 8.dp else 0
)
}
}
with(segmentTable) {
addView(createSegmentOperation(feeSegment.operation))
feeSegment.feeComponents.forEach {
val componentViews = createFeeComponentViews(it)
componentViews.forEach(::addView)
}
}
binder.swapFeeContent.addView(segmentTable)
}
private fun createFeeComponentViews(model: SwapComponentFeeModel): List<View> {
return model.individualFees.mapIndexed { index, feeDisplay ->
val isFirst = index == 0
val isLast = index == model.individualFees.size - 1
val label = model.label.takeIf { isFirst }
FeeView(requireContext()).apply {
setShouldDrawDivider(isLast)
setTitle(label)
setFeeDisplay(feeDisplay)
}
}
}
private fun createSegmentOperation(model: FeeOperationModel): View {
return SwapRouteTableCellView(requireContext()).apply {
setShowChainNames(true)
setTitle(model.label)
setSwapRouteModel(model.swapRoute)
}
}
}
@@ -0,0 +1,113 @@
package io.novafoundation.nova.feature_swap_impl.presentation.fee
import androidx.lifecycle.viewModelScope
import io.novafoundation.nova.common.base.BaseViewModel
import io.novafoundation.nova.common.resources.ResourceManager
import io.novafoundation.nova.common.utils.withSafeLoading
import io.novafoundation.nova.feature_account_api.data.mappers.mapChainToUi
import io.novafoundation.nova.feature_swap_api.domain.model.AtomicOperationDisplayData
import io.novafoundation.nova.feature_swap_api.domain.model.AtomicOperationFeeDisplayData
import io.novafoundation.nova.feature_swap_api.domain.model.AtomicOperationFeeDisplayData.SwapFeeType
import io.novafoundation.nova.feature_swap_api.domain.model.SwapFee
import io.novafoundation.nova.feature_swap_impl.R
import io.novafoundation.nova.feature_swap_impl.domain.interactor.SwapInteractor
import io.novafoundation.nova.feature_swap_impl.presentation.common.route.SwapRouteModel
import io.novafoundation.nova.feature_swap_impl.presentation.common.state.SwapStateStoreProvider
import io.novafoundation.nova.feature_swap_impl.presentation.common.state.stateFlow
import io.novafoundation.nova.feature_swap_impl.presentation.fee.model.SwapSegmentFeeModel
import io.novafoundation.nova.feature_swap_impl.presentation.fee.model.SwapSegmentFeeModel.FeeOperationModel
import io.novafoundation.nova.feature_swap_impl.presentation.fee.model.SwapSegmentFeeModel.SwapComponentFeeModel
import io.novafoundation.nova.feature_wallet_api.domain.model.Token
import io.novafoundation.nova.feature_wallet_api.presentation.formatters.amount.AmountFormatter
import io.novafoundation.nova.feature_wallet_api.presentation.formatters.amount.formatAmountToAmountModel
import io.novafoundation.nova.feature_wallet_api.presentation.formatters.formatAsCurrency
import io.novafoundation.nova.feature_wallet_api.presentation.mixin.fee.model.toFeeDisplay
import io.novafoundation.nova.runtime.ext.fullId
import io.novafoundation.nova.runtime.multiNetwork.ChainRegistry
import io.novafoundation.nova.runtime.multiNetwork.chain.model.FullChainAssetId
import kotlinx.coroutines.flow.map
class SwapFeeViewModel(
private val swapInteractor: SwapInteractor,
private val chainRegistry: ChainRegistry,
private val resourceManager: ResourceManager,
private val amountFormatter: AmountFormatter,
swapStateStoreProvider: SwapStateStoreProvider
) : BaseViewModel() {
private val swapStateFlow = swapStateStoreProvider.stateFlow(viewModelScope)
val swapFeeSegments = swapStateFlow
.map { it.fee.toSwapFeeSegments() }
.withSafeLoading()
.shareInBackground()
val totalFee = swapStateFlow.map {
val fee = swapInteractor.calculateTotalFiatPrice(it.fee)
fee.formatAsCurrency()
}.shareInBackground()
private suspend fun SwapFee.toSwapFeeSegments(): List<SwapSegmentFeeModel> {
val allTokens = swapInteractor.getAllFeeTokens(this)
return segments.map { segment ->
val operationData = segment.operation.constructDisplayData()
val feeDisplayData = segment.fee.constructDisplayData()
SwapSegmentFeeModel(
operation = operationData.toFeeOperationModel(),
feeComponents = feeDisplayData.toFeeComponentModels(allTokens)
)
}
}
private fun AtomicOperationFeeDisplayData.toFeeComponentModels(
tokens: Map<FullChainAssetId, Token>
): List<SwapComponentFeeModel> {
return components.map { feeDisplaySegment ->
SwapComponentFeeModel(
label = feeDisplaySegment.type.formatLabel(),
individualFees = feeDisplaySegment.fees.map { individualFee ->
val token = tokens.getValue(individualFee.asset.fullId)
amountFormatter.formatAmountToAmountModel(individualFee.amount, token).toFeeDisplay()
}
)
}
}
private fun SwapFeeType.formatLabel(): String {
return when (this) {
SwapFeeType.NETWORK -> resourceManager.getString(R.string.network_fee)
SwapFeeType.CROSS_CHAIN -> resourceManager.getString(R.string.wallet_send_cross_chain_fee)
}
}
private suspend fun AtomicOperationDisplayData.toFeeOperationModel(): FeeOperationModel {
return when (this) {
is AtomicOperationDisplayData.Swap -> toFeeOperationModel()
is AtomicOperationDisplayData.Transfer -> toFeeOperationModel()
}
}
private suspend fun AtomicOperationDisplayData.Swap.toFeeOperationModel(): FeeOperationModel {
val chain = chainRegistry.getChain(from.chainAssetId.chainId)
val chains = listOf(mapChainToUi(chain))
return FeeOperationModel(
label = resourceManager.getString(R.string.swap_route_segment_swap_title),
swapRoute = SwapRouteModel(chains)
)
}
private suspend fun AtomicOperationDisplayData.Transfer.toFeeOperationModel(): FeeOperationModel {
val chainFrom = chainRegistry.getChain(from.chainId)
val chainTo = chainRegistry.getChain(to.chainId)
val chains = listOf(mapChainToUi(chainFrom), mapChainToUi(chainTo))
return FeeOperationModel(
label = resourceManager.getString(R.string.swap_route_segment_transfer_title),
swapRoute = SwapRouteModel(chains)
)
}
}
@@ -0,0 +1,26 @@
package io.novafoundation.nova.feature_swap_impl.presentation.fee.di
import androidx.fragment.app.Fragment
import dagger.BindsInstance
import dagger.Subcomponent
import io.novafoundation.nova.common.di.scope.ScreenScope
import io.novafoundation.nova.feature_swap_impl.presentation.fee.SwapFeeFragment
@Subcomponent(
modules = [
SwapFeeModule::class
]
)
@ScreenScope
interface SwapFeeComponent {
@Subcomponent.Factory
interface Factory {
fun create(
@BindsInstance fragment: Fragment,
): SwapFeeComponent
}
fun inject(fragment: SwapFeeFragment)
}
@@ -0,0 +1,47 @@
package io.novafoundation.nova.feature_swap_impl.presentation.fee.di
import androidx.fragment.app.Fragment
import androidx.lifecycle.ViewModel
import androidx.lifecycle.ViewModelProvider
import dagger.Module
import dagger.Provides
import dagger.multibindings.IntoMap
import io.novafoundation.nova.common.di.viewmodel.ViewModelKey
import io.novafoundation.nova.common.di.viewmodel.ViewModelModule
import io.novafoundation.nova.common.resources.ResourceManager
import io.novafoundation.nova.feature_swap_impl.domain.interactor.SwapInteractor
import io.novafoundation.nova.feature_swap_impl.presentation.common.state.SwapStateStoreProvider
import io.novafoundation.nova.feature_swap_impl.presentation.fee.SwapFeeViewModel
import io.novafoundation.nova.feature_wallet_api.presentation.formatters.amount.AmountFormatter
import io.novafoundation.nova.runtime.multiNetwork.ChainRegistry
@Module(includes = [ViewModelModule::class])
class SwapFeeModule {
@Provides
@IntoMap
@ViewModelKey(SwapFeeViewModel::class)
fun provideViewModel(
swapInteractor: SwapInteractor,
chainRegistry: ChainRegistry,
resourceManager: ResourceManager,
swapStateStoreProvider: SwapStateStoreProvider,
amountFormatter: AmountFormatter
): ViewModel {
return SwapFeeViewModel(
swapInteractor = swapInteractor,
chainRegistry = chainRegistry,
resourceManager = resourceManager,
swapStateStoreProvider = swapStateStoreProvider,
amountFormatter = amountFormatter
)
}
@Provides
fun provideViewModelCreator(
fragment: Fragment,
viewModelFactory: ViewModelProvider.Factory
): SwapFeeViewModel {
return ViewModelProvider(fragment, viewModelFactory).get(SwapFeeViewModel::class.java)
}
}
@@ -0,0 +1,14 @@
package io.novafoundation.nova.feature_swap_impl.presentation.fee.model
import io.novafoundation.nova.feature_swap_impl.presentation.common.route.SwapRouteModel
import io.novafoundation.nova.feature_wallet_api.presentation.mixin.fee.model.FeeDisplay
class SwapSegmentFeeModel(
val operation: FeeOperationModel,
val feeComponents: List<SwapComponentFeeModel>
) {
class SwapComponentFeeModel(val label: String, val individualFees: List<FeeDisplay>)
class FeeOperationModel(val label: String, val swapRoute: SwapRouteModel)
}
@@ -0,0 +1,25 @@
package io.novafoundation.nova.feature_swap_impl.presentation.main
import io.novafoundation.nova.common.domain.ExtendedLoadingState
import io.novafoundation.nova.feature_swap_api.domain.model.SwapQuote
import io.novafoundation.nova.feature_swap_api.domain.model.SwapQuoteArgs
sealed class QuotingState {
object Default : QuotingState()
object Loading : QuotingState()
data class Error(val error: Throwable) : QuotingState()
data class Loaded(val quote: SwapQuote, val quoteArgs: SwapQuoteArgs) : QuotingState()
}
inline fun <T> QuotingState.toLoadingState(onLoaded: (SwapQuote) -> T?): ExtendedLoadingState<T?> {
return when (this) {
QuotingState.Default -> ExtendedLoadingState.Loaded(null)
is QuotingState.Error -> ExtendedLoadingState.Error(error)
is QuotingState.Loaded -> ExtendedLoadingState.Loaded(onLoaded(quote))
QuotingState.Loading -> ExtendedLoadingState.Loading
}
}
@@ -0,0 +1,100 @@
package io.novafoundation.nova.feature_swap_impl.presentation.main
import android.os.Bundle
import io.novafoundation.nova.common.base.BaseFragment
import io.novafoundation.nova.common.di.FeatureUtils
import io.novafoundation.nova.common.mixin.impl.observeValidations
import io.novafoundation.nova.common.utils.hideKeyboard
import io.novafoundation.nova.common.utils.postToUiThread
import io.novafoundation.nova.common.utils.setSelectionEnd
import io.novafoundation.nova.common.utils.setVisible
import io.novafoundation.nova.common.view.bottomSheet.description.observeDescription
import io.novafoundation.nova.common.view.setProgressState
import io.novafoundation.nova.common.view.setState
import io.novafoundation.nova.common.view.showLoadingValue
import io.novafoundation.nova.feature_swap_api.di.SwapFeatureApi
import io.novafoundation.nova.feature_swap_api.presentation.model.SwapSettingsPayload
import io.novafoundation.nova.feature_swap_core_api.data.primitive.model.SwapDirection
import io.novafoundation.nova.feature_swap_impl.di.SwapFeatureComponent
import io.novafoundation.nova.feature_swap_impl.presentation.main.input.setupSwapAmountInput
import io.novafoundation.nova.feature_swap_impl.databinding.FragmentMainSwapSettingsBinding
import io.novafoundation.nova.feature_wallet_api.presentation.mixin.fee.v2.setupFeeLoading
import io.novafoundation.nova.feature_wallet_api.presentation.mixin.getAsset.bindGetAsset
class SwapMainSettingsFragment : BaseFragment<SwapMainSettingsViewModel, FragmentMainSwapSettingsBinding>() {
companion object {
private const val KEY_PAYLOAD = "SwapMainSettingsFragment.payload"
fun getBundle(payload: SwapSettingsPayload): Bundle {
return Bundle().apply {
putParcelable(KEY_PAYLOAD, payload)
}
}
}
override fun createBinding() = FragmentMainSwapSettingsBinding.inflate(layoutInflater)
override fun initViews() {
binder.swapMainSettingsContinue.prepareForProgress(this)
binder.swapMainSettingsToolbar.setHomeButtonListener { viewModel.backClicked() }
binder.swapMainSettingsToolbar.setRightActionClickListener { viewModel.openOptions() }
binder.swapMainSettingsPayInput.setSelectTokenClickListener { viewModel.selectPayToken() }
binder.swapMainSettingsReceiveInput.setSelectTokenClickListener { viewModel.selectReceiveToken() }
binder.swapMainSettingsFlip.setOnClickListener {
viewModel.flipAssets()
}
binder.swapMainSettingsDetailsRate.setOnClickListener { viewModel.rateDetailsClicked() }
binder.swapMainSettingsDetailsNetworkFee.setOnClickListener { viewModel.networkFeeClicked() }
binder.swapMainSettingsContinue.setOnClickListener { viewModel.continueButtonClicked() }
binder.swapMainSettingsContinue.prepareForProgress(this)
binder.swapMainSettingsRoute.setOnClickListener {
viewModel.routeClicked()
hideKeyboard()
}
}
override fun inject() {
FeatureUtils.getFeature<SwapFeatureComponent>(
requireContext(),
SwapFeatureApi::class.java
)
.swapMainSettings()
.create(this, argument(KEY_PAYLOAD))
.inject(this)
}
override fun subscribe(viewModel: SwapMainSettingsViewModel) {
observeDescription(viewModel)
observeValidations(viewModel)
setupSwapAmountInput(viewModel.amountInInput, binder.swapMainSettingsPayInput, binder.swapMainSettingsMaxAmount)
setupSwapAmountInput(viewModel.amountOutInput, binder.swapMainSettingsReceiveInput, maxAvailableView = null)
viewModel.getAssetOptionsMixin.bindGetAsset(binder.swapMainSettingsGetAssetIn)
viewModel.feeMixin.setupFeeLoading(binder.swapMainSettingsDetailsNetworkFee)
viewModel.rateDetails.observe { binder.swapMainSettingsDetailsRate.showLoadingValue(it) }
viewModel.swapRouteState.observe(binder.swapMainSettingsRoute::setSwapRouteState)
viewModel.swapExecutionTime.observe(binder.swapMainSettingsExecutionTime::showLoadingValue)
viewModel.showDetails.observe { binder.swapMainSettingsDetails.setVisible(it) }
viewModel.buttonState.observe(binder.swapMainSettingsContinue::setState)
viewModel.swapDirectionFlipped.observeEvent {
postToUiThread {
val field = when (it) {
SwapDirection.SPECIFIED_IN -> binder.swapMainSettingsPayInput
SwapDirection.SPECIFIED_OUT -> binder.swapMainSettingsReceiveInput
}
field.requestFocus()
field.amountInput.setSelectionEnd()
}
}
viewModel.validationProgress.observe(binder.swapMainSettingsContinue::setProgressState)
}
}
@@ -0,0 +1,737 @@
package io.novafoundation.nova.feature_swap_impl.presentation.main
import android.util.Log
import androidx.lifecycle.MutableLiveData
import androidx.lifecycle.viewModelScope
import io.novafoundation.nova.common.base.BaseViewModel
import io.novafoundation.nova.common.domain.ExtendedLoadingState
import io.novafoundation.nova.common.mixin.actionAwaitable.ActionAwaitableMixin
import io.novafoundation.nova.common.mixin.api.Validatable
import io.novafoundation.nova.common.presentation.DescriptiveButtonState
import io.novafoundation.nova.common.resources.ResourceManager
import io.novafoundation.nova.common.utils.Event
import io.novafoundation.nova.common.utils.LOG_TAG
import io.novafoundation.nova.common.utils.accumulate
import io.novafoundation.nova.common.utils.event
import io.novafoundation.nova.common.utils.flowOfAll
import io.novafoundation.nova.common.utils.formatting.CompoundNumberFormatter
import io.novafoundation.nova.common.utils.formatting.DynamicPrecisionFormatter
import io.novafoundation.nova.common.utils.formatting.FixedPrecisionFormatter
import io.novafoundation.nova.common.utils.formatting.NumberAbbreviation
import io.novafoundation.nova.common.utils.inBackground
import io.novafoundation.nova.common.utils.invoke
import io.novafoundation.nova.common.utils.launchUnit
import io.novafoundation.nova.common.utils.nullOnStart
import io.novafoundation.nova.common.utils.sendEvent
import io.novafoundation.nova.common.utils.skipFirst
import io.novafoundation.nova.common.utils.zipWithPrevious
import io.novafoundation.nova.common.validation.CompoundFieldValidator
import io.novafoundation.nova.common.validation.FieldValidationResult
import io.novafoundation.nova.common.validation.FieldValidator
import io.novafoundation.nova.common.validation.ValidationExecutor
import io.novafoundation.nova.common.validation.ValidationFlowActions
import io.novafoundation.nova.common.validation.ValidationStatus
import io.novafoundation.nova.common.validation.isErrorWithTag
import io.novafoundation.nova.common.validation.progressConsumer
import io.novafoundation.nova.common.view.bottomSheet.description.DescriptionBottomSheetLauncher
import io.novafoundation.nova.feature_account_api.domain.interfaces.SelectedAccountUseCase
import io.novafoundation.nova.feature_swap_api.domain.model.SwapFee
import io.novafoundation.nova.feature_swap_api.domain.model.SwapQuote
import io.novafoundation.nova.feature_swap_api.domain.model.SwapQuoteArgs
import io.novafoundation.nova.feature_swap_api.domain.model.swapRate
import io.novafoundation.nova.feature_swap_api.domain.model.toExecuteArgs
import io.novafoundation.nova.feature_swap_api.domain.model.totalTime
import io.novafoundation.nova.feature_swap_api.presentation.formatters.SwapRateFormatter
import io.novafoundation.nova.feature_swap_api.presentation.model.SwapSettingsPayload
import io.novafoundation.nova.feature_swap_api.presentation.model.mapFromModel
import io.novafoundation.nova.feature_swap_api.presentation.navigation.SwapFlowScopeAggregator
import io.novafoundation.nova.feature_swap_api.presentation.state.SwapSettings
import io.novafoundation.nova.feature_swap_api.presentation.state.SwapSettingsStateProvider
import io.novafoundation.nova.feature_swap_api.presentation.view.bottomSheet.description.launchSwapRateDescription
import io.novafoundation.nova.feature_swap_core_api.data.primitive.model.SwapDirection
import io.novafoundation.nova.feature_swap_impl.R
import io.novafoundation.nova.feature_swap_impl.domain.interactor.SwapInteractor
import io.novafoundation.nova.feature_swap_impl.domain.validation.SwapValidationFailure
import io.novafoundation.nova.feature_swap_impl.domain.validation.SwapValidationPayload
import io.novafoundation.nova.feature_swap_impl.domain.validation.toSwapState
import io.novafoundation.nova.feature_swap_impl.presentation.SwapRouter
import io.novafoundation.nova.feature_swap_impl.presentation.common.fee.createForSwap
import io.novafoundation.nova.feature_swap_impl.presentation.common.fieldValidation.LiquidityFieldValidatorFactory
import io.novafoundation.nova.feature_swap_impl.presentation.common.fieldValidation.SwapReceiveAmountAboveEDFieldValidatorFactory
import io.novafoundation.nova.feature_wallet_api.presentation.mixin.maxAction.MaxActionProviderFactory
import io.novafoundation.nova.feature_swap_impl.presentation.common.route.SwapRouteFormatter
import io.novafoundation.nova.feature_swap_impl.presentation.common.route.SwapRouteState
import io.novafoundation.nova.feature_swap_impl.presentation.common.state.SwapState
import io.novafoundation.nova.feature_swap_impl.presentation.common.state.SwapStateStoreProvider
import io.novafoundation.nova.feature_swap_impl.presentation.common.state.setState
import io.novafoundation.nova.feature_swap_impl.presentation.common.state.swapSettingsFlow
import io.novafoundation.nova.feature_swap_impl.presentation.main.input.SwapAmountInputMixin
import io.novafoundation.nova.feature_swap_impl.presentation.main.input.SwapAmountInputMixinFactory
import io.novafoundation.nova.feature_swap_impl.presentation.main.input.SwapInputMixinPriceImpactFiatFormatterFactory
import io.novafoundation.nova.feature_wallet_api.data.network.blockhain.types.Balance
import io.novafoundation.nova.feature_wallet_api.domain.ArbitraryAssetUseCase
import io.novafoundation.nova.feature_wallet_api.domain.model.Asset
import io.novafoundation.nova.feature_wallet_api.domain.model.Token
import io.novafoundation.nova.feature_wallet_api.domain.model.amountFromPlanks
import io.novafoundation.nova.feature_wallet_api.domain.model.planksFromAmount
import io.novafoundation.nova.feature_wallet_api.presentation.common.fieldValidator.EnoughAmountFieldValidator
import io.novafoundation.nova.feature_wallet_api.presentation.common.fieldValidator.EnoughAmountValidatorFactory
import io.novafoundation.nova.feature_wallet_api.presentation.mixin.amountChooser.AmountChooserMixinBase.InputState
import io.novafoundation.nova.feature_wallet_api.presentation.mixin.amountChooser.AmountChooserMixinBase.InputState.InputKind
import io.novafoundation.nova.feature_wallet_api.presentation.mixin.amountChooser.invokeMaxClick
import io.novafoundation.nova.feature_wallet_api.presentation.mixin.amountChooser.maxAction.MaxActionProvider
import io.novafoundation.nova.feature_wallet_api.presentation.mixin.amountChooser.setAmount
import io.novafoundation.nova.feature_wallet_api.presentation.mixin.fee.model.FeeDisplay
import io.novafoundation.nova.feature_wallet_api.presentation.mixin.fee.model.FeeStatus
import io.novafoundation.nova.feature_wallet_api.presentation.mixin.fee.model.PaymentCurrencySelectionMode
import io.novafoundation.nova.feature_wallet_api.presentation.mixin.fee.v2.FeeLoaderMixinV2
import io.novafoundation.nova.feature_wallet_api.presentation.mixin.fee.v2.FeeLoaderMixinV2.Configuration
import io.novafoundation.nova.feature_wallet_api.presentation.mixin.fee.v2.awaitFee
import io.novafoundation.nova.feature_wallet_api.presentation.mixin.fee.v2.awaitOptionalFee
import io.novafoundation.nova.feature_wallet_api.presentation.mixin.getAsset.GetAssetOptionsMixin
import io.novafoundation.nova.feature_wallet_api.presentation.mixin.maxAction.create
import io.novafoundation.nova.feature_wallet_api.presentation.model.AssetPayload
import io.novafoundation.nova.feature_wallet_api.presentation.model.fullChainAssetId
import io.novafoundation.nova.runtime.ext.fullId
import io.novafoundation.nova.runtime.multiNetwork.ChainRegistry
import io.novafoundation.nova.runtime.multiNetwork.asset
import io.novafoundation.nova.runtime.multiNetwork.chain.model.Chain
import kotlinx.coroutines.CancellationException
import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.Job
import kotlinx.coroutines.async
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.catch
import kotlinx.coroutines.flow.combine
import kotlinx.coroutines.flow.debounce
import kotlinx.coroutines.flow.distinctUntilChanged
import kotlinx.coroutines.flow.emitAll
import kotlinx.coroutines.flow.filter
import kotlinx.coroutines.flow.filterIsInstance
import kotlinx.coroutines.flow.filterNotNull
import kotlinx.coroutines.flow.first
import kotlinx.coroutines.flow.firstOrNull
import kotlinx.coroutines.flow.launchIn
import kotlinx.coroutines.flow.map
import kotlinx.coroutines.flow.mapLatest
import kotlinx.coroutines.flow.mapNotNull
import kotlinx.coroutines.flow.onEach
import kotlinx.coroutines.flow.transformLatest
import kotlinx.coroutines.launch
import java.math.BigDecimal
import kotlin.time.Duration.Companion.milliseconds
@OptIn(ExperimentalCoroutinesApi::class)
class SwapMainSettingsViewModel(
private val swapRouter: SwapRouter,
private val swapInteractor: SwapInteractor,
private val swapSettingsStateProvider: SwapSettingsStateProvider,
private val resourceManager: ResourceManager,
private val chainRegistry: ChainRegistry,
private val assetUseCase: ArbitraryAssetUseCase,
private val payload: SwapSettingsPayload,
private val validationExecutor: ValidationExecutor,
private val liquidityFieldValidatorFactory: LiquidityFieldValidatorFactory,
private val swapReceiveAmountAboveEDFieldValidatorFactory: SwapReceiveAmountAboveEDFieldValidatorFactory,
private val enoughAmountValidatorFactory: EnoughAmountValidatorFactory,
private val swapInputMixinPriceImpactFiatFormatterFactory: SwapInputMixinPriceImpactFiatFormatterFactory,
private val selectedAccountUseCase: SelectedAccountUseCase,
private val descriptionBottomSheetLauncher: DescriptionBottomSheetLauncher,
private val swapRateFormatter: SwapRateFormatter,
private val swapRouteFormatter: SwapRouteFormatter,
private val maxActionProviderFactory: MaxActionProviderFactory,
private val swapStateStoreProvider: SwapStateStoreProvider,
private val swapFlowScopeAggregator: SwapFlowScopeAggregator,
private val getAssetOptionsMixinFactory: GetAssetOptionsMixin.Factory,
swapAmountInputMixinFactory: SwapAmountInputMixinFactory,
feeLoaderMixinFactory: FeeLoaderMixinV2.Factory,
actionAwaitableFactory: ActionAwaitableMixin.Factory,
) : BaseViewModel(),
DescriptionBottomSheetLauncher by descriptionBottomSheetLauncher,
Validatable by validationExecutor {
private val swapFlowScope = swapFlowScopeAggregator.getFlowScope(viewModelScope)
private val swapSettingState = async {
swapSettingsStateProvider.getSwapSettingsState(swapFlowScope)
}
private val swapSettings = swapSettingsStateProvider.swapSettingsFlow(swapFlowScope)
.share()
private val chainAssetIn = swapSettings
.map { it.assetIn }
.distinctUntilChanged()
.shareInBackground()
private val quotingState = MutableStateFlow<QuotingState>(QuotingState.Default)
private val assetOutFlow = swapSettings.assetFlowOf(SwapSettings::assetOut)
private val assetInFlow = swapSettings.assetFlowOf(SwapSettings::assetIn)
private val priceImpact = quotingState.map { quoteState ->
when (quoteState) {
is QuotingState.Error, QuotingState.Loading, QuotingState.Default -> null
is QuotingState.Loaded -> quoteState.quote.priceImpact
}
}
val swapRouteState = quotingState
.map { quoteState -> quoteState.toSwapRouteState() }
.shareInBackground()
val swapExecutionTime = quotingState
.map { it.toExecutionEstimate() }
.shareInBackground()
private val originChainFlow = swapSettings
.mapNotNull { it.assetIn?.chainId }
.distinctUntilChanged()
.map { chainRegistry.getChain(it) }
.shareInBackground()
val feeMixin = feeLoaderMixinFactory.createForSwap(
chainAssetIn = swapSettings.mapNotNull { it.assetIn },
interactor = swapInteractor,
configuration = Configuration(
initialState = Configuration.InitialState(
paymentCurrencySelectionMode = PaymentCurrencySelectionMode.AUTOMATIC_ONLY
)
)
)
private val maxAssetInProvider = createMaxActionProvider()
val amountInInput = swapAmountInputMixinFactory.create(
coroutineScope = viewModelScope,
tokenFlow = assetInFlow.token().nullOnStart(),
emptyAssetTitle = R.string.swap_field_asset_from_title,
maxActionProvider = maxAssetInProvider,
fieldValidator = getAmountInFieldValidator()
)
val amountOutInput = swapAmountInputMixinFactory.create(
coroutineScope = viewModelScope,
tokenFlow = assetOutFlow.token().nullOnStart(),
emptyAssetTitle = R.string.swap_field_asset_to_title,
fiatFormatter = swapInputMixinPriceImpactFiatFormatterFactory.create(priceImpact),
fieldValidator = getAmountOutFieldValidator()
)
val rateDetails: Flow<ExtendedLoadingState<String>> = quotingState.map {
when (it) {
is QuotingState.Loaded -> ExtendedLoadingState.Loaded(formatRate(it.quote))
else -> ExtendedLoadingState.Loading
}
}
.shareInBackground()
val showDetails: Flow<Boolean> = quotingState.mapNotNull {
when (it) {
is QuotingState.Loaded -> true
is QuotingState.Default,
is QuotingState.Error -> false
else -> null // Don't do anything if it's loading state
}
}
.distinctUntilChanged()
.shareInBackground()
private val _validationProgress = MutableStateFlow(false)
val validationProgress = _validationProgress
val buttonState: Flow<DescriptiveButtonState> = combine(
quotingState,
accumulate(amountInInput.fieldError, amountOutInput.fieldError),
accumulate(amountInInput.inputState, amountOutInput.inputState),
assetInFlow,
assetOutFlow,
::formatButtonStates
)
.distinctUntilChanged()
.debounce(100)
.shareInBackground()
val swapDirectionFlipped: MutableLiveData<Event<SwapDirection>> = MutableLiveData()
private val notEnoughAmountErrorFlow = amountInInput.fieldError.map { it.isErrorWithTag(EnoughAmountFieldValidator.ERROR_TAG) }
val getAssetOptionsMixin = getAssetOptionsMixinFactory.create(
assetFlow = chainAssetIn,
additionalButtonFilter = notEnoughAmountErrorFlow,
scope = viewModelScope,
)
private val amountInputFormatter = CompoundNumberFormatter(
abbreviations = listOf(
NumberAbbreviation(
threshold = BigDecimal.ZERO,
divisor = BigDecimal.ONE,
suffix = "",
formatter = DynamicPrecisionFormatter(minScale = 5, minPrecision = 3)
),
NumberAbbreviation(
threshold = BigDecimal.ONE,
divisor = BigDecimal.ONE,
suffix = "",
formatter = FixedPrecisionFormatter(precision = 5)
),
)
)
private var quotingJob: Job? = null
init {
initPayload()
launch { swapInteractor.warmUpSwapCommonlyUsedChains(swapFlowScope) }
handleInputChanges(amountInInput, SwapSettings::assetIn, SwapDirection.SPECIFIED_IN)
handleInputChanges(amountOutInput, SwapSettings::assetOut, SwapDirection.SPECIFIED_OUT)
setupQuoting()
setupUpdateSystem()
feeMixin.setupFees()
launch {
swapInteractor.sync(swapFlowScope)
}
}
fun selectPayToken() {
launch {
val outAsset = assetOutFlow.firstOrNull()
?.token
?.configuration
val payload = outAsset?.let { AssetPayload(it.chainId, it.id) }
swapRouter.selectAssetIn(payload)
}
}
fun selectReceiveToken() {
launch {
val inAsset = assetInFlow.firstOrNull()
?.token
?.configuration
val payload = inAsset?.let { AssetPayload(it.chainId, it.id) }
swapRouter.selectAssetOut(payload)
}
}
fun routeClicked() = setSwapStateAfter {
swapRouter.openSwapRoute()
}
fun networkFeeClicked() = setSwapStateAndThen {
swapRouter.openSwapFee()
}
fun continueButtonClicked() = launchUnit {
val validationSystem = swapInteractor.validationSystem()
val payload = getValidationPayload() ?: return@launchUnit
validationExecutor.requireValid(
validationSystem = validationSystem,
payload = payload,
progressConsumer = _validationProgress.progressConsumer(),
validationFailureTransformerCustom = ::formatValidationFailure,
) { validPayload ->
_validationProgress.value = false
openSwapConfirmation(validPayload)
}
}
fun rateDetailsClicked() {
launchSwapRateDescription()
}
fun flipAssets() = launch {
val previousSettings = swapSettings.first()
val newSettings = swapSettingState().flipAssets()
applyFlipToUi(previousSettings, newSettings)
}
fun openOptions() {
swapRouter.openSwapOptions()
}
fun backClicked() {
swapRouter.back()
}
private fun openSwapConfirmation(validPayload: SwapValidationPayload) = launchUnit {
swapStateStoreProvider.setState(validPayload.toSwapState())
swapRouter.openSwapConfirmation()
}
private fun setSwapStateAndThen(action: suspend () -> Unit) {
launch {
val quotingState = quotingState.value
if (quotingState !is QuotingState.Loaded) return@launch
val swapState = SwapState(
quote = quotingState.quote,
fee = feeMixin.awaitFee(),
slippage = swapSettings.first().slippage
)
swapStateStoreProvider.getStore(swapFlowScope).setState(swapState)
action()
}
}
private fun setSwapStateAfter(action: () -> Unit) {
launch {
val quotingState = quotingState.value
if (quotingState !is QuotingState.Loaded) return@launch
val store = swapStateStoreProvider.getStore(swapFlowScope)
store.resetState()
action()
val swapState = SwapState(
quote = quotingState.quote,
fee = feeMixin.awaitFee(),
slippage = swapSettings.first().slippage
)
store.setState(swapState)
}
}
private fun createMaxActionProvider(): MaxActionProvider {
return maxActionProviderFactory.create(
viewModelScope = viewModelScope,
assetInFlow = assetInFlow.filterNotNull(),
feeLoaderMixin = feeMixin,
)
}
private fun initPayload() {
launch {
val assetIn = chainRegistry.asset(payload.assetIn.fullChainAssetId)
val swapSettingsState = swapSettingState.await()
when (payload) {
is SwapSettingsPayload.DefaultFlow -> swapSettingState().setAssetIn(assetIn)
is SwapSettingsPayload.RepeatOperation -> {
val assetOut = chainRegistry.asset(payload.assetOut.fullChainAssetId)
val oldSwapSettings = swapSettingsState.selectedOption.first()
val direction = payload.direction.mapFromModel()
val swapSettings = SwapSettings(
assetIn = assetIn,
assetOut = assetOut,
amount = payload.amount,
swapDirection = direction,
slippage = oldSwapSettings.slippage
)
swapSettingsState.setSwapSettings(swapSettings)
initInputSilently(direction, assetIn, assetOut, payload.amount)
}
}
}
}
private fun initInputSilently(direction: SwapDirection, assetIn: Chain.Asset, assetOut: Chain.Asset, amount: Balance) {
when (direction) {
SwapDirection.SPECIFIED_IN -> {
amountInInput.updateInput(assetIn, amount)
}
SwapDirection.SPECIFIED_OUT -> {
amountOutInput.updateInput(assetOut, amount)
}
}
}
private fun setupUpdateSystem() = launch {
swapInteractor.getUpdateSystem(originChainFlow, swapFlowScope)
.start()
.launchIn(viewModelScope)
}
private fun FeeLoaderMixinV2.Presentation<SwapFee, FeeDisplay>.setupFees() {
quotingState
.onEach {
when (it) {
is QuotingState.Loading -> setFeeLoading()
is QuotingState.Error -> setFeeStatus(FeeStatus.NoFee)
else -> {}
}
}
.filterIsInstance<QuotingState.Loaded>()
.debounce(300.milliseconds)
.zipWithPrevious()
.mapNotNull { (previous, current) ->
current.takeIf {
// allow same value in case user quickly switched from this value to another and back without waiting for fee loading
previous != current || feeMixin.fee.value !is FeeStatus.Loaded
}
}
.onEach { quoteState ->
loadFee { feePaymentCurrency ->
val swapArgs = quoteState.quote.toExecuteArgs(
slippage = swapSettings.first().slippage,
firstSegmentFees = feePaymentCurrency
)
swapInteractor.estimateFee(swapArgs)
}
}
.inBackground()
.launchIn(viewModelScope)
}
private fun applyFlipToUi(previousSettings: SwapSettings, newSettings: SwapSettings) {
val amount = previousSettings.amount ?: return
val swapDirection = previousSettings.swapDirection ?: return
when (swapDirection) {
SwapDirection.SPECIFIED_IN -> {
val previousIn = previousSettings.assetIn ?: return
amountOutInput.updateInput(previousIn, amount)
amountInInput.clearInput()
}
SwapDirection.SPECIFIED_OUT -> {
val previousOut = previousSettings.assetOut ?: return
amountInInput.updateInput(previousOut, amount)
amountOutInput.clearInput()
}
}
swapDirectionFlipped.value = newSettings.swapDirection!!.event()
}
private fun formatRate(swapQuote: SwapQuote): String {
return swapRateFormatter.format(swapQuote.swapRate(), swapQuote.assetIn, swapQuote.assetOut)
}
private fun formatButtonStates(
quotingState: QuotingState,
errorStates: List<FieldValidationResult>,
inputs: List<InputState<String>>,
assetIn: Asset?,
assetOut: Asset?,
): DescriptiveButtonState {
return when {
assetIn == null -> {
DescriptiveButtonState.Disabled(resourceManager.getString(R.string.swap_main_settings_asset_in_not_selecting_button_state))
}
assetOut == null -> {
DescriptiveButtonState.Disabled(resourceManager.getString(R.string.swap_main_settings_asset_out_not_selecting_button_state))
}
inputs.all { it.value.isEmpty() } -> {
DescriptiveButtonState.Disabled(resourceManager.getString(R.string.swap_main_settings_enter_amount_disabled_button_state))
}
errorStates.any { it is FieldValidationResult.Error } -> {
DescriptiveButtonState.Disabled(resourceManager.getString(R.string.swap_main_settings_wrong_amount_disabled_button_state))
}
quotingState is QuotingState.Loading -> DescriptiveButtonState.Loading
quotingState is QuotingState.Error || inputs.any { it.value.isEmpty() } -> {
DescriptiveButtonState.Disabled(resourceManager.getString(R.string.common_continue))
}
else -> DescriptiveButtonState.Enabled(resourceManager.getString(R.string.common_continue))
}
}
private fun setupQuoting() {
setupPerSwapSettingQuoting()
setupSubscriptionQuoting()
}
private fun setupPerSwapSettingQuoting() {
swapSettings.mapLatest { performQuote(it, shouldShowLoading = true) }
.launchIn(viewModelScope)
}
private fun setupSubscriptionQuoting() {
flowOfAll {
swapInteractor.runSubscriptions(selectedAccountUseCase.getSelectedMetaAccount())
.catch { Log.e(this@SwapMainSettingsViewModel.LOG_TAG, "Failure during subscriptions run", it) }
}.onEach {
Log.d("Swap", "ReQuote triggered from subscription")
val currentSwapSettings = swapSettings.first()
performQuote(currentSwapSettings, shouldShowLoading = false)
}.launchIn(viewModelScope)
}
private fun performQuote(swapSettings: SwapSettings, shouldShowLoading: Boolean) {
quotingJob?.cancel()
quotingJob = launch {
val swapQuoteArgs = swapSettings.toQuoteArgs(
tokenIn = { assetInFlow.ensureToken(it) },
tokenOut = { assetOutFlow.ensureToken(it) },
) ?: return@launch
if (shouldShowLoading) {
quotingState.value = QuotingState.Loading
}
val quote = swapInteractor.quote(swapQuoteArgs, swapFlowScope)
quotingState.value = quote.fold(
onSuccess = { QuotingState.Loaded(it, swapQuoteArgs) },
onFailure = {
if (it is CancellationException) {
QuotingState.Loading
} else {
QuotingState.Error(it)
}
}
)
handleNewQuote(quote, swapSettings)
}
}
private suspend fun QuotingState.toSwapRouteState(): SwapRouteState {
return toLoadingState { swapRouteFormatter.formatSwapRoute(it) }
}
private fun QuotingState.toExecutionEstimate(): ExtendedLoadingState<String?> {
return toLoadingState {
val estimatedDuration = it.executionEstimate.totalTime()
resourceManager.formatDuration(estimatedDuration, estimated = true)
}
}
private fun handleNewQuote(quoteResult: Result<SwapQuote>, swapSettings: SwapSettings) {
quoteResult.onSuccess { quote ->
when (swapSettings.swapDirection!!) {
SwapDirection.SPECIFIED_IN -> amountOutInput.updateInput(quote.assetOut, quote.planksOut)
SwapDirection.SPECIFIED_OUT -> amountInInput.updateInput(quote.assetIn, quote.planksIn)
}
}.onFailure {
when (swapSettings.swapDirection!!) {
SwapDirection.SPECIFIED_OUT -> amountInInput.clearInput()
SwapDirection.SPECIFIED_IN -> amountOutInput.clearInput()
}
}
}
private inline fun SwapSettings.toQuoteArgs(
tokenIn: (Chain.Asset) -> Token,
tokenOut: (Chain.Asset) -> Token
): SwapQuoteArgs? {
return if (assetIn != null && assetOut != null && amount != null && swapDirection != null) {
SwapQuoteArgs(
tokenIn = tokenIn(assetIn!!),
tokenOut = tokenOut(assetOut!!),
amount = amount!!,
swapDirection = swapDirection!!,
)
} else {
null
}
}
private fun handleInputChanges(
amountInput: SwapAmountInputMixin.Presentation,
chainAsset: (SwapSettings) -> Chain.Asset?,
swapDirection: SwapDirection
) {
amountInput.amountState
.filter { it.initiatedByUser }
.skipFirst()
.onEach { state ->
val asset = chainAsset(swapSettings.first()) ?: return@onEach
val planks = state.value?.let(asset::planksFromAmount)
swapSettingState().setAmount(planks, swapDirection)
}.launchIn(viewModelScope)
}
private fun SwapAmountInputMixin.clearInput() {
inputState.value = InputState(value = "", initiatedByUser = false, inputKind = InputKind.REGULAR)
}
private fun SwapAmountInputMixin.updateInput(chainAsset: Chain.Asset, planks: Balance) {
val amount = chainAsset.amountFromPlanks(planks)
inputState.value = InputState(amountInputFormatter.format(amount), initiatedByUser = false, InputKind.REGULAR)
}
private fun Flow<SwapSettings>.assetFlowOf(extractor: (SwapSettings) -> Chain.Asset?): Flow<Asset?> {
return map { extractor(it) }
.transformLatest { chainAsset ->
if (chainAsset == null) {
emit(null)
} else {
emitAll(assetUseCase.assetFlow(chainAsset))
}
}
.shareInBackground()
}
private suspend fun Flow<Asset?>.ensureToken(asset: Chain.Asset): Token {
return filterNotNull()
.first { it.token.configuration.fullId == asset.fullId }
.token
}
private fun getAmountInFieldValidator(): FieldValidator {
return CompoundFieldValidator(
enoughAmountValidatorFactory.create(maxAssetInProvider, errorMessageRes = R.string.swap_field_validation_not_enough_amount_to_swap),
liquidityFieldValidatorFactory.create(quotingState)
)
}
private fun getAmountOutFieldValidator(): FieldValidator {
return swapReceiveAmountAboveEDFieldValidatorFactory.create(assetOutFlow)
}
private suspend fun getValidationPayload(): SwapValidationPayload? {
val quotingState = quotingState.value
if (quotingState !is QuotingState.Loaded) return null
val fee = feeMixin.awaitOptionalFee() ?: return null
val slippage = swapSettings.first().slippage
return SwapValidationPayload(fee, quotingState.quote, slippage)
}
private fun formatValidationFailure(
status: ValidationStatus.NotValid<SwapValidationFailure>,
actions: ValidationFlowActions<SwapValidationPayload>
) = mapSwapValidationFailureToUI(
resourceManager = resourceManager,
status = status,
actions = actions,
amountInSwapMaxAction = ::setMaxAvailableAmountIn,
amountOutSwapMinAction = ::setMinAmountOut,
)
private fun setMaxAvailableAmountIn() {
launch {
amountInInput.invokeMaxClick()
}
}
private fun setMinAmountOut(chainAsset: Chain.Asset, amountInPlanks: Balance) {
amountOutInput.requestFocusLiveData.sendEvent()
amountOutInput.setAmount(chainAsset.amountFromPlanks(amountInPlanks))
}
private fun Flow<Asset?>.token(): Flow<Token?> = map { it?.token }
}
@@ -0,0 +1,243 @@
package io.novafoundation.nova.feature_swap_impl.presentation.main
import io.novafoundation.nova.common.base.TitleAndMessage
import io.novafoundation.nova.common.mixin.api.CustomDialogDisplayer
import io.novafoundation.nova.common.resources.ResourceManager
import io.novafoundation.nova.common.utils.formatting.formatPercents
import io.novafoundation.nova.common.validation.TransformedFailure
import io.novafoundation.nova.common.validation.ValidationFlowActions
import io.novafoundation.nova.common.validation.ValidationStatus
import io.novafoundation.nova.common.validation.asDefault
import io.novafoundation.nova.feature_swap_impl.domain.validation.SwapValidationFailure
import io.novafoundation.nova.feature_swap_impl.domain.validation.SwapValidationFailure.AmountOutIsTooLowToStayAboveED
import io.novafoundation.nova.feature_swap_impl.domain.validation.SwapValidationFailure.InsufficientBalance
import io.novafoundation.nova.feature_swap_impl.domain.validation.SwapValidationFailure.InvalidSlippage
import io.novafoundation.nova.feature_swap_impl.domain.validation.SwapValidationFailure.NewRateExceededSlippage
import io.novafoundation.nova.feature_swap_impl.domain.validation.SwapValidationFailure.NonPositiveAmount
import io.novafoundation.nova.feature_swap_impl.domain.validation.SwapValidationFailure.NotEnoughFunds
import io.novafoundation.nova.feature_swap_impl.domain.validation.SwapValidationFailure.NotEnoughLiquidity
import io.novafoundation.nova.feature_swap_impl.domain.validation.SwapValidationFailure.TooSmallRemainingBalance
import io.novafoundation.nova.feature_wallet_api.R
import io.novafoundation.nova.feature_wallet_api.data.network.blockhain.types.Balance
import io.novafoundation.nova.feature_wallet_api.domain.validation.amountIsTooBig
import io.novafoundation.nova.feature_wallet_api.presentation.formatters.formatPlanks
import io.novafoundation.nova.feature_wallet_api.presentation.formatters.formatTokenAmount
import io.novafoundation.nova.feature_wallet_api.presentation.validation.handleInsufficientBalanceCommission
import io.novafoundation.nova.feature_wallet_api.presentation.validation.handleNonPositiveAmount
import io.novafoundation.nova.runtime.multiNetwork.chain.model.Chain
import java.math.BigDecimal
import java.math.BigInteger
fun mapSwapValidationFailureToUI(
resourceManager: ResourceManager,
status: ValidationStatus.NotValid<SwapValidationFailure>,
actions: ValidationFlowActions<*>,
amountInSwapMaxAction: () -> Unit,
amountOutSwapMinAction: (Chain.Asset, Balance) -> Unit
): TransformedFailure {
return when (val reason = status.reason) {
is NotEnoughFunds.ToPayFeeAndStayAboveED -> handleInsufficientBalanceCommission(reason, resourceManager).asDefault()
NotEnoughFunds.InUsedAsset -> resourceManager.amountIsTooBig().asDefault()
is InvalidSlippage -> TitleAndMessage(
resourceManager.getString(R.string.swap_invalid_slippage_failure_title),
resourceManager.getString(R.string.swap_invalid_slippage_failure_message, reason.minSlippage.formatPercents(), reason.maxSlippage.formatPercents())
).asDefault()
is SwapValidationFailure.HighPriceImpact -> highPriceImpact(reason, resourceManager, actions)
is NewRateExceededSlippage -> TitleAndMessage(
resourceManager.getString(R.string.swap_rate_was_updated_failure_title),
resourceManager.getString(
R.string.swap_rate_was_updated_failure_message,
BigDecimal.ONE.formatTokenAmount(reason.assetIn),
reason.selectedRate.formatTokenAmount(reason.assetOut),
reason.newRate.formatTokenAmount(reason.assetOut)
)
).asDefault()
NonPositiveAmount -> handleNonPositiveAmount(resourceManager).asDefault()
NotEnoughLiquidity -> TitleAndMessage(resourceManager.getString(R.string.swap_not_enought_liquidity_failure), second = null).asDefault()
is AmountOutIsTooLowToStayAboveED -> handleErrorToSwapMin(reason, resourceManager, amountOutSwapMinAction)
is InsufficientBalance.CannotPayFeeDueToAmount -> handleInsufficientBalance(
title = resourceManager.getString(R.string.common_not_enough_funds_title),
message = resourceManager.getString(
R.string.swap_failure_insufficient_balance_message,
reason.maxSwapAmount.formatTokenAmount(reason.assetIn),
reason.feeAmount.formatTokenAmount(reason.assetIn)
),
resourceManager = resourceManager,
positiveButtonClick = amountInSwapMaxAction
)
is InsufficientBalance.BalanceNotConsiderConsumers -> TitleAndMessage(
resourceManager.getString(R.string.common_not_enough_funds_title),
resourceManager.getString(
R.string.swap_failure_balance_not_consider_consumers,
reason.assetInED.formatPlanks(reason.assetIn),
reason.fee.formatPlanks(reason.feeAsset)
)
).asDefault()
is InsufficientBalance.BalanceNotConsiderInsufficientReceiveAsset -> TitleAndMessage(
resourceManager.getString(R.string.common_not_enough_funds_title),
resourceManager.getString(
R.string.swap_failure_balance_not_consider_non_sufficient_assets,
reason.existentialDeposit.formatPlanks(reason.assetIn),
reason.assetOut.symbol
)
).asDefault()
is TooSmallRemainingBalance -> handleTooSmallRemainingBalance(
title = resourceManager.getString(R.string.swap_failure_too_small_remaining_balance_title),
message = resourceManager.getString(
R.string.swap_failure_too_small_remaining_balance_message,
reason.assetInExistentialDeposit.formatPlanks(reason.assetIn),
reason.remainingBalance.formatPlanks(reason.assetIn)
),
resourceManager = resourceManager,
actions = actions,
positiveButtonClick = amountInSwapMaxAction
)
is InsufficientBalance.CannotPayFee -> TitleAndMessage(
resourceManager.getString(R.string.common_not_enough_funds_title),
resourceManager.getString(
R.string.common_not_enough_to_pay_fee_message,
reason.fee.formatPlanks(reason.feeAsset),
reason.balance.formatPlanks(reason.feeAsset)
)
).asDefault()
is SwapValidationFailure.IntermediateAmountOutIsTooLowToStayAboveED -> TitleAndMessage(
resourceManager.getString(R.string.swap_too_low_amount_to_stay_abow_ed_title),
resourceManager.getString(
R.string.swap_intermediate_too_low_amount_to_stay_abow_ed_message,
reason.amount.formatPlanks(reason.asset),
reason.existentialDeposit.formatPlanks(reason.asset)
)
).asDefault()
is SwapValidationFailure.CannotReceiveAssetOut -> TitleAndMessage(
resourceManager.getString(R.string.common_not_enough_funds_title),
resourceManager.getString(
R.string.swap_failure_cannot_receive_insufficient_asset_out,
reason.requiredNativeAssetOnChainOut.formatPlanks(),
reason.destination.chain.name,
reason.destination.asset.symbol
)
).asDefault()
}
}
fun handleInsufficientBalance(
title: String,
message: String,
resourceManager: ResourceManager,
positiveButtonClick: () -> Unit
): TransformedFailure {
return handleErrorToSwapMax(
title = title,
message = message,
resourceManager = resourceManager,
negativeButtonText = resourceManager.getString(R.string.common_cancel),
positiveButtonClick = positiveButtonClick,
negativeButtonClick = { }
)
}
fun handleTooSmallRemainingBalance(
title: String,
message: String,
resourceManager: ResourceManager,
actions: ValidationFlowActions<*>,
positiveButtonClick: () -> Unit
): TransformedFailure {
return handleErrorToSwapMax(
title = title,
message = message,
resourceManager = resourceManager,
negativeButtonText = resourceManager.getString(R.string.common_proceed),
positiveButtonClick = positiveButtonClick,
negativeButtonClick = { actions.resumeFlow() }
)
}
fun handleErrorToSwapMax(
title: String,
message: String,
resourceManager: ResourceManager,
negativeButtonText: String,
positiveButtonClick: () -> Unit,
negativeButtonClick: () -> Unit
): TransformedFailure {
return TransformedFailure.Custom(
CustomDialogDisplayer.Payload(
title = title,
message = message,
customStyle = R.style.AccentAlertDialogTheme,
okAction = CustomDialogDisplayer.Payload.DialogAction(
title = resourceManager.getString(R.string.swap_failure_swap_max_button),
action = positiveButtonClick
),
cancelAction = CustomDialogDisplayer.Payload.DialogAction(
title = negativeButtonText,
action = negativeButtonClick
)
)
)
}
fun handleErrorToSwapMin(
reason: AmountOutIsTooLowToStayAboveED,
resourceManager: ResourceManager,
swapMinAmountAction: (Chain.Asset, BigInteger) -> Unit
): TransformedFailure {
return TransformedFailure.Custom(
CustomDialogDisplayer.Payload(
title = resourceManager.getString(R.string.swap_too_low_amount_to_stay_abow_ed_title),
message = resourceManager.getString(
R.string.swap_too_low_amount_to_stay_abow_ed_message,
reason.amountInPlanks.formatPlanks(reason.asset),
reason.existentialDeposit.formatPlanks(reason.asset),
),
customStyle = R.style.AccentAlertDialogTheme,
okAction = CustomDialogDisplayer.Payload.DialogAction(
title = resourceManager.getString(R.string.swap_failure_swap_min_button),
action = { swapMinAmountAction(reason.asset, reason.existentialDeposit) }
),
cancelAction = CustomDialogDisplayer.Payload.DialogAction(
title = resourceManager.getString(R.string.common_cancel),
action = { }
)
)
)
}
fun highPriceImpact(
reason: SwapValidationFailure.HighPriceImpact,
resourceManager: ResourceManager,
actions: ValidationFlowActions<*>
): TransformedFailure {
return TransformedFailure.Custom(
CustomDialogDisplayer.Payload(
title = resourceManager.getString(R.string.high_price_impact_detacted_title, reason.priceImpact.formatPercents()),
message = resourceManager.getString(R.string.high_price_impact_detacted_message),
customStyle = R.style.AccentNegativeAlertDialogTheme_Reversed,
okAction = CustomDialogDisplayer.Payload.DialogAction(
title = resourceManager.getString(R.string.common_continue),
action = {
actions.resumeFlow()
}
),
cancelAction = CustomDialogDisplayer.Payload.DialogAction(
title = resourceManager.getString(R.string.common_cancel),
action = { }
)
)
)
}
@@ -0,0 +1,28 @@
package io.novafoundation.nova.feature_swap_impl.presentation.main.di
import androidx.fragment.app.Fragment
import dagger.BindsInstance
import dagger.Subcomponent
import io.novafoundation.nova.common.di.scope.ScreenScope
import io.novafoundation.nova.feature_swap_impl.presentation.main.SwapMainSettingsFragment
import io.novafoundation.nova.feature_swap_api.presentation.model.SwapSettingsPayload
@Subcomponent(
modules = [
SwapMainSettingsModule::class
]
)
@ScreenScope
interface SwapMainSettingsComponent {
@Subcomponent.Factory
interface Factory {
fun create(
@BindsInstance fragment: Fragment,
@BindsInstance payload: SwapSettingsPayload
): SwapMainSettingsComponent
}
fun inject(fragment: SwapMainSettingsFragment)
}
@@ -0,0 +1,135 @@
package io.novafoundation.nova.feature_swap_impl.presentation.main.di
import androidx.fragment.app.Fragment
import androidx.lifecycle.ViewModel
import androidx.lifecycle.ViewModelProvider
import dagger.Module
import dagger.Provides
import dagger.multibindings.IntoMap
import io.novafoundation.nova.common.di.scope.ScreenScope
import io.novafoundation.nova.common.di.viewmodel.ViewModelKey
import io.novafoundation.nova.common.di.viewmodel.ViewModelModule
import io.novafoundation.nova.common.mixin.actionAwaitable.ActionAwaitableMixin
import io.novafoundation.nova.common.presentation.AssetIconProvider
import io.novafoundation.nova.common.resources.ResourceManager
import io.novafoundation.nova.common.validation.ValidationExecutor
import io.novafoundation.nova.common.view.bottomSheet.description.DescriptionBottomSheetLauncher
import io.novafoundation.nova.feature_account_api.domain.interfaces.SelectedAccountUseCase
import io.novafoundation.nova.feature_swap_api.presentation.formatters.SwapRateFormatter
import io.novafoundation.nova.feature_swap_api.presentation.model.SwapSettingsPayload
import io.novafoundation.nova.feature_swap_api.presentation.navigation.SwapFlowScopeAggregator
import io.novafoundation.nova.feature_swap_api.presentation.state.SwapSettingsStateProvider
import io.novafoundation.nova.feature_swap_impl.domain.interactor.SwapInteractor
import io.novafoundation.nova.feature_swap_impl.presentation.SwapRouter
import io.novafoundation.nova.feature_swap_impl.presentation.common.PriceImpactFormatter
import io.novafoundation.nova.feature_swap_impl.presentation.common.fieldValidation.LiquidityFieldValidatorFactory
import io.novafoundation.nova.feature_swap_impl.presentation.common.fieldValidation.SwapReceiveAmountAboveEDFieldValidatorFactory
import io.novafoundation.nova.feature_wallet_api.presentation.mixin.maxAction.MaxActionProviderFactory
import io.novafoundation.nova.feature_swap_impl.presentation.common.route.SwapRouteFormatter
import io.novafoundation.nova.feature_swap_impl.presentation.common.state.SwapStateStoreProvider
import io.novafoundation.nova.feature_swap_impl.presentation.main.SwapMainSettingsViewModel
import io.novafoundation.nova.feature_swap_impl.presentation.main.input.SwapAmountInputMixinFactory
import io.novafoundation.nova.feature_swap_impl.presentation.main.input.SwapInputMixinPriceImpactFiatFormatterFactory
import io.novafoundation.nova.feature_wallet_api.data.network.blockhain.assets.AssetSourceRegistry
import io.novafoundation.nova.feature_wallet_api.domain.ArbitraryAssetUseCase
import io.novafoundation.nova.feature_wallet_api.presentation.common.fieldValidator.EnoughAmountValidatorFactory
import io.novafoundation.nova.feature_wallet_api.presentation.mixin.fee.v2.FeeLoaderMixinV2
import io.novafoundation.nova.feature_wallet_api.presentation.mixin.getAsset.GetAssetOptionsMixin
import io.novafoundation.nova.runtime.multiNetwork.ChainRegistry
@Module(includes = [ViewModelModule::class])
class SwapMainSettingsModule {
@Provides
@ScreenScope
fun provideSwapAmountMixinFactory(
chainRegistry: ChainRegistry,
resourceManager: ResourceManager,
assetIconProvider: AssetIconProvider
) = SwapAmountInputMixinFactory(chainRegistry, resourceManager, assetIconProvider)
@Provides
@ScreenScope
fun provideSwapInputMixinPriceImpactFiatFormatterFactory(
priceImpactFormatter: PriceImpactFormatter
) = SwapInputMixinPriceImpactFiatFormatterFactory(priceImpactFormatter)
@Provides
@ScreenScope
fun provideLiquidityFieldValidatorFactory(resourceManager: ResourceManager): LiquidityFieldValidatorFactory {
return LiquidityFieldValidatorFactory(resourceManager)
}
@Provides
@ScreenScope
fun provideSwapAmountAboveEDFieldValidatorFactory(
resourceManager: ResourceManager,
chainRegistry: ChainRegistry,
assetSourceRegistry: AssetSourceRegistry
): SwapReceiveAmountAboveEDFieldValidatorFactory {
return SwapReceiveAmountAboveEDFieldValidatorFactory(resourceManager, chainRegistry, assetSourceRegistry)
}
@Provides
@IntoMap
@ViewModelKey(SwapMainSettingsViewModel::class)
fun provideViewModel(
swapRouter: SwapRouter,
swapInteractor: SwapInteractor,
resourceManager: ResourceManager,
swapSettingsStateProvider: SwapSettingsStateProvider,
swapAmountInputMixinFactory: SwapAmountInputMixinFactory,
chainRegistry: ChainRegistry,
assetUseCase: ArbitraryAssetUseCase,
feeLoaderMixinFactory: FeeLoaderMixinV2.Factory,
actionAwaitableMixinFactory: ActionAwaitableMixin.Factory,
liquidityFieldValidatorFactory: LiquidityFieldValidatorFactory,
swapReceiveAmountAboveEDFieldValidatorFactory: SwapReceiveAmountAboveEDFieldValidatorFactory,
payload: SwapSettingsPayload,
swapInputMixinPriceImpactFiatFormatterFactory: SwapInputMixinPriceImpactFiatFormatterFactory,
accountUseCase: SelectedAccountUseCase,
validationExecutor: ValidationExecutor,
descriptionBottomSheetLauncher: DescriptionBottomSheetLauncher,
swapRateFormatter: SwapRateFormatter,
maxActionProviderFactory: MaxActionProviderFactory,
swapStateStoreProvider: SwapStateStoreProvider,
swapRouteFormatter: SwapRouteFormatter,
swapFlowScopeAggregator: SwapFlowScopeAggregator,
enoughAmountValidatorFactory: EnoughAmountValidatorFactory,
getAssetOptionsMixinFactory: GetAssetOptionsMixin.Factory,
): ViewModel {
return SwapMainSettingsViewModel(
swapRouter = swapRouter,
swapInteractor = swapInteractor,
swapSettingsStateProvider = swapSettingsStateProvider,
resourceManager = resourceManager,
swapAmountInputMixinFactory = swapAmountInputMixinFactory,
chainRegistry = chainRegistry,
assetUseCase = assetUseCase,
feeLoaderMixinFactory = feeLoaderMixinFactory,
actionAwaitableFactory = actionAwaitableMixinFactory,
payload = payload,
swapInputMixinPriceImpactFiatFormatterFactory = swapInputMixinPriceImpactFiatFormatterFactory,
validationExecutor = validationExecutor,
liquidityFieldValidatorFactory = liquidityFieldValidatorFactory,
swapReceiveAmountAboveEDFieldValidatorFactory = swapReceiveAmountAboveEDFieldValidatorFactory,
descriptionBottomSheetLauncher = descriptionBottomSheetLauncher,
getAssetOptionsMixinFactory = getAssetOptionsMixinFactory,
swapRateFormatter = swapRateFormatter,
selectedAccountUseCase = accountUseCase,
enoughAmountValidatorFactory = enoughAmountValidatorFactory,
swapStateStoreProvider = swapStateStoreProvider,
maxActionProviderFactory = maxActionProviderFactory,
swapRouteFormatter = swapRouteFormatter,
swapFlowScopeAggregator = swapFlowScopeAggregator
)
}
@Provides
fun provideViewModelCreator(
fragment: Fragment,
viewModelFactory: ViewModelProvider.Factory
): SwapMainSettingsViewModel {
return ViewModelProvider(fragment, viewModelFactory).get(SwapMainSettingsViewModel::class.java)
}
}
@@ -0,0 +1,27 @@
package io.novafoundation.nova.feature_swap_impl.presentation.main.input
import io.novafoundation.nova.common.utils.images.Icon
import io.novafoundation.nova.feature_wallet_api.presentation.mixin.amountChooser.AmountChooserMixinBase
import kotlinx.coroutines.flow.Flow
interface SwapAmountInputMixin : AmountChooserMixinBase {
val assetModel: Flow<SwapInputAssetModel>
interface Presentation : SwapAmountInputMixin, AmountChooserMixinBase.Presentation
class SwapInputAssetModel(
val assetIcon: SwapAssetIcon,
val title: String,
val subtitleIcon: Icon?,
val subtitle: String,
val showInput: Boolean,
) {
sealed class SwapAssetIcon {
class Chosen(val assetIcon: Icon) : SwapAssetIcon()
object NotChosen : SwapAssetIcon()
}
}
}
@@ -0,0 +1,100 @@
package io.novafoundation.nova.feature_swap_impl.presentation.main.input
import androidx.annotation.StringRes
import io.novafoundation.nova.common.presentation.AssetIconProvider
import io.novafoundation.nova.common.resources.ResourceManager
import io.novafoundation.nova.common.validation.FieldValidator
import io.novafoundation.nova.feature_account_api.presenatation.chain.getAssetIconOrFallback
import io.novafoundation.nova.feature_account_api.presenatation.chain.iconOrFallback
import io.novafoundation.nova.feature_swap_impl.R
import io.novafoundation.nova.feature_swap_impl.presentation.main.input.SwapAmountInputMixin.SwapInputAssetModel
import io.novafoundation.nova.feature_wallet_api.domain.model.Token
import io.novafoundation.nova.feature_wallet_api.presentation.mixin.amountChooser.AmountChooserMixinBase
import io.novafoundation.nova.feature_wallet_api.presentation.mixin.amountChooser.BaseAmountChooserProvider
import io.novafoundation.nova.feature_wallet_api.presentation.mixin.amountChooser.DefaultFiatFormatter
import io.novafoundation.nova.feature_wallet_api.presentation.mixin.amountChooser.maxAction.MaxActionProvider
import io.novafoundation.nova.runtime.multiNetwork.ChainRegistry
import io.novafoundation.nova.runtime.multiNetwork.chain.model.Chain
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.map
class SwapAmountInputMixinFactory(
private val chainRegistry: ChainRegistry,
private val resourceManager: ResourceManager,
private val assetIconProvider: AssetIconProvider
) {
fun create(
coroutineScope: CoroutineScope,
tokenFlow: Flow<Token?>,
@StringRes emptyAssetTitle: Int,
maxActionProvider: MaxActionProvider? = null,
fiatFormatter: AmountChooserMixinBase.FiatFormatter = DefaultFiatFormatter(),
fieldValidator: FieldValidator
): SwapAmountInputMixin.Presentation {
return RealSwapAmountInputMixin(
coroutineScope = coroutineScope,
tokenFlow = tokenFlow,
emptyAssetTitle = emptyAssetTitle,
chainRegistry = chainRegistry,
resourceManager = resourceManager,
maxActionProvider = maxActionProvider,
fiatFormatter = fiatFormatter,
fieldValidator = fieldValidator,
assetIconProvider = assetIconProvider
)
}
}
private class RealSwapAmountInputMixin(
coroutineScope: CoroutineScope,
tokenFlow: Flow<Token?>,
@StringRes private val emptyAssetTitle: Int,
private val chainRegistry: ChainRegistry,
private val resourceManager: ResourceManager,
maxActionProvider: MaxActionProvider?,
fiatFormatter: AmountChooserMixinBase.FiatFormatter,
fieldValidator: FieldValidator,
private val assetIconProvider: AssetIconProvider
) : BaseAmountChooserProvider(
coroutineScope = coroutineScope,
tokenFlow = tokenFlow,
maxActionProvider = maxActionProvider,
fiatFormatter = fiatFormatter,
fieldValidator = fieldValidator
),
SwapAmountInputMixin.Presentation {
override val assetModel: Flow<SwapInputAssetModel> = tokenFlow.map {
val chainAsset = it?.configuration
if (chainAsset != null) {
formatInputAsset(chainAsset)
} else {
defaultInputModel()
}
}
private suspend fun formatInputAsset(chainAsset: Chain.Asset): SwapInputAssetModel {
val chain = chainRegistry.getChain(chainAsset.chainId)
return SwapInputAssetModel(
assetIcon = SwapInputAssetModel.SwapAssetIcon.Chosen(assetIconProvider.getAssetIconOrFallback(chainAsset)),
title = chainAsset.symbol.value,
subtitleIcon = chain.iconOrFallback(),
subtitle = chain.name,
showInput = true,
)
}
private fun defaultInputModel(): SwapInputAssetModel {
return SwapInputAssetModel(
assetIcon = SwapInputAssetModel.SwapAssetIcon.NotChosen,
title = resourceManager.getString(emptyAssetTitle),
subtitleIcon = null,
subtitle = resourceManager.getString(R.string.fragment_swap_main_settings_select_token),
showInput = false,
)
}
}
@@ -0,0 +1,16 @@
package io.novafoundation.nova.feature_swap_impl.presentation.main.input
import io.novafoundation.nova.common.base.BaseFragment
import io.novafoundation.nova.feature_swap_impl.presentation.common.views.SwapAmountInputView
import io.novafoundation.nova.feature_wallet_api.presentation.mixin.amountChooser.MaxAvailableView
import io.novafoundation.nova.feature_wallet_api.presentation.mixin.amountChooser.setupAmountChooserBase
fun BaseFragment<*, *>.setupSwapAmountInput(
mixin: SwapAmountInputMixin,
amountView: SwapAmountInputView,
maxAvailableView: MaxAvailableView?
) {
setupAmountChooserBase(mixin, amountView, maxAvailableView)
mixin.assetModel.observe(amountView::setModel)
}
@@ -0,0 +1,42 @@
package io.novafoundation.nova.feature_swap_impl.presentation.main.input
import android.text.SpannableStringBuilder
import io.novafoundation.nova.common.utils.Fraction
import io.novafoundation.nova.feature_currency_api.presentation.formatters.formatAsCurrency
import io.novafoundation.nova.feature_swap_impl.presentation.common.PriceImpactFormatter
import io.novafoundation.nova.feature_wallet_api.domain.model.Token
import io.novafoundation.nova.feature_wallet_api.presentation.mixin.amountChooser.AmountChooserMixinBase
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.combine
import java.math.BigDecimal
class SwapInputMixinPriceImpactFiatFormatterFactory(
private val priceImpactFormatter: PriceImpactFormatter,
) {
fun create(priceImpactFlow: Flow<Fraction?>): AmountChooserMixinBase.FiatFormatter {
return SwapInputMixinPriceImpactFiatFormatter(priceImpactFormatter, priceImpactFlow)
}
}
class SwapInputMixinPriceImpactFiatFormatter(
private val priceImpactFormatter: PriceImpactFormatter,
private val priceImpactFlow: Flow<Fraction?>,
) : AmountChooserMixinBase.FiatFormatter {
override fun formatFlow(tokenFlow: Flow<Token>, amountFlow: Flow<BigDecimal>): Flow<CharSequence> {
return combine(tokenFlow, amountFlow, priceImpactFlow) { token, amount, priceImpact ->
val formattedFiatAmount = token.amountToFiat(amount).formatAsCurrency(token.currency)
val formattedPriceImpact = priceImpact?.let(priceImpactFormatter::formatWithBrackets)
if (formattedPriceImpact != null) {
SpannableStringBuilder().apply {
append("$formattedFiatAmount ")
append(formattedPriceImpact)
}
} else {
formattedFiatAmount
}
}
}
}
@@ -0,0 +1,53 @@
package io.novafoundation.nova.feature_swap_impl.presentation.options
import androidx.lifecycle.viewModelScope
import io.novafoundation.nova.common.base.BaseFragment
import io.novafoundation.nova.common.di.FeatureUtils
import io.novafoundation.nova.common.utils.bindTo
import io.novafoundation.nova.common.validation.observeErrors
import io.novafoundation.nova.common.view.bottomSheet.description.observeDescription
import io.novafoundation.nova.common.view.setState
import io.novafoundation.nova.common.view.setMessageOrHide
import io.novafoundation.nova.feature_swap_api.di.SwapFeatureApi
import io.novafoundation.nova.feature_swap_impl.R
import io.novafoundation.nova.feature_swap_impl.databinding.FragmentSwapOptionsBinding
import io.novafoundation.nova.feature_swap_impl.di.SwapFeatureComponent
class SwapOptionsFragment : BaseFragment<SwapOptionsViewModel, FragmentSwapOptionsBinding>() {
override fun createBinding() = FragmentSwapOptionsBinding.inflate(layoutInflater)
override fun initViews() {
binder.swapOptionsToolbar.setHomeButtonListener { viewModel.backClicked() }
binder.swapOptionsToolbar.setRightActionClickListener { viewModel.resetClicked() }
binder.swapOptionsApplyButton.setOnClickListener { viewModel.applyClicked() }
binder.swapOptionsSlippageTitle.setOnClickListener { viewModel.slippageInfoClicked() }
}
override fun inject() {
FeatureUtils.getFeature<SwapFeatureComponent>(
requireContext(),
SwapFeatureApi::class.java
)
.swapOptions()
.create(this)
.inject(this)
}
override fun subscribe(viewModel: SwapOptionsViewModel) {
observeDescription(viewModel)
binder.swapOptionsSlippageInput.content.bindTo(viewModel.slippageInput, viewModel.viewModelScope, moveSelectionToEndOnInsertion = true)
viewModel.defaultSlippage.observe { binder.swapOptionsSlippageInput.setHint(it) }
viewModel.slippageTips.observe {
binder.swapOptionsSlippageInput.clearTips()
it.forEachIndexed { index, text ->
binder.swapOptionsSlippageInput.addTextTip(text, R.color.text_primary) { viewModel.tipClicked(index) }
}
}
viewModel.buttonState.observe { binder.swapOptionsApplyButton.setState(it) }
binder.swapOptionsSlippageInput.observeErrors(viewModel.slippageInputValidationResult, viewModel.viewModelScope)
viewModel.slippageWarningState.observe { binder.swapOptionsAlert.setMessageOrHide(it) }
viewModel.resetButtonEnabled.observe { binder.swapOptionsToolbar.setRightActionEnabled(it) }
}
}
@@ -0,0 +1,150 @@
package io.novafoundation.nova.feature_swap_impl.presentation.options
import androidx.lifecycle.viewModelScope
import io.novafoundation.nova.common.base.BaseViewModel
import io.novafoundation.nova.common.presentation.DescriptiveButtonState
import io.novafoundation.nova.common.presentation.DescriptiveButtonState.Disabled
import io.novafoundation.nova.common.presentation.DescriptiveButtonState.Enabled
import io.novafoundation.nova.common.resources.ResourceManager
import io.novafoundation.nova.common.utils.Fraction
import io.novafoundation.nova.common.utils.Fraction.Companion.percents
import io.novafoundation.nova.common.utils.flowOfAll
import io.novafoundation.nova.common.utils.formatting.formatPercents
import io.novafoundation.nova.common.utils.mapList
import io.novafoundation.nova.common.validation.FieldValidationResult
import io.novafoundation.nova.common.view.bottomSheet.description.DescriptionBottomSheetLauncher
import io.novafoundation.nova.feature_swap_api.domain.model.SlippageConfig
import io.novafoundation.nova.feature_swap_api.presentation.state.SwapSettings
import io.novafoundation.nova.feature_swap_api.presentation.state.SwapSettingsStateProvider
import io.novafoundation.nova.feature_swap_impl.R
import io.novafoundation.nova.feature_swap_impl.domain.interactor.SwapInteractor
import io.novafoundation.nova.feature_swap_impl.presentation.SwapRouter
import io.novafoundation.nova.feature_swap_impl.presentation.common.SlippageAlertMixinFactory
import io.novafoundation.nova.feature_swap_impl.presentation.common.fieldValidation.SlippageFieldValidatorFactory
import kotlinx.coroutines.async
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.combine
import kotlinx.coroutines.flow.first
import kotlinx.coroutines.flow.flatMapLatest
import kotlinx.coroutines.flow.map
import kotlinx.coroutines.flow.mapNotNull
import kotlinx.coroutines.launch
class SwapOptionsViewModel(
private val swapRouter: SwapRouter,
private val resourceManager: ResourceManager,
private val swapSettingsStateProvider: SwapSettingsStateProvider,
private val slippageFieldValidatorFactory: SlippageFieldValidatorFactory,
private val swapInteractor: SwapInteractor,
private val descriptionBottomSheetLauncher: DescriptionBottomSheetLauncher,
private val slippageAlertMixinFactory: SlippageAlertMixinFactory
) : BaseViewModel(), DescriptionBottomSheetLauncher by descriptionBottomSheetLauncher {
private val swapSettingState = async {
swapSettingsStateProvider.getSwapSettingsState(viewModelScope)
}
private val swapSettingsStateFlow = flowOfAll { swapSettingState.await().selectedOption }
.shareInBackground()
private val slippageConfig = swapSettingsStateFlow
.mapNotNull { it.assetIn ?: it.assetOut }
.mapNotNull { swapInteractor.slippageConfig(it.chainId) }
.shareInBackground()
val slippageInput = MutableStateFlow("")
private val slippageFieldValidator = slippageConfig.map { slippageFieldValidatorFactory.create(it) }
.shareInBackground()
val slippageInputValidationResult = slippageFieldValidator.flatMapLatest { it.observe(slippageInput) }
.shareInBackground()
private val slippageAlertMixin = slippageAlertMixinFactory.create(
slippageConfig,
slippageInput.map { it.formatToPercent() }
)
val slippageWarningState = slippageAlertMixin.slippageAlertMessage
val resetButtonEnabled = combine(slippageInput, slippageConfig) { input, slippageConfig ->
formatResetButtonVisibility(input, slippageConfig)
}
val buttonState = combine(slippageInput, swapSettingsStateFlow, slippageInputValidationResult) { input, state, validationStatus ->
formatButtonState(input, state, validationStatus)
}
val defaultSlippage = slippageConfig.map { it.defaultSlippage }
.map { it.formatPercents() }
val slippageTips = slippageConfig.map { it.slippageTips }
.mapList { it.formatPercents() }
init {
launch {
val selectedSlippage = swapSettingsStateFlow.first().slippage
val defaultSlippage = slippageConfig.first().defaultSlippage
if (selectedSlippage != defaultSlippage) {
slippageInput.value = selectedSlippage.formatPercents(includeSymbol = false)
}
}
}
fun slippageInfoClicked() {
launchDescriptionBottomSheet(
titleRes = R.string.swap_slippage_title,
descriptionRes = R.string.swap_slippage_description
)
}
fun tipClicked(index: Int) {
launch {
val slippageTips = slippageConfig.first().slippageTips
slippageInput.value = slippageTips[index].formatPercents(includeSymbol = false)
}
}
fun applyClicked() {
launch {
val slippage = slippageInput.value.formatToPercent() ?: return@launch
swapSettingState.await().setSlippage(slippage)
swapRouter.back()
}
}
fun resetClicked() {
slippageInput.value = ""
}
fun backClicked() {
swapRouter.back()
}
private suspend fun String.formatToPercent(): Fraction? {
val defaultSlippage = slippageConfig.first().defaultSlippage
return if (isEmpty()) {
defaultSlippage
} else {
return toDoubleOrNull()?.percents
}
}
private suspend fun formatButtonState(
insertedSlippage: String,
settings: SwapSettings,
validationResult: FieldValidationResult
): DescriptiveButtonState {
val slippage = insertedSlippage.formatToPercent()
return when {
validationResult is FieldValidationResult.Error -> Disabled(resourceManager.getString(R.string.swap_slippage_disabled_button_state))
slippage != settings.slippage -> Enabled(resourceManager.getString(R.string.common_apply))
else -> Disabled(resourceManager.getString(R.string.common_apply))
}
}
private suspend fun formatResetButtonVisibility(slippageInput: String, slippageConfig: SlippageConfig): Boolean {
return slippageInput.formatToPercent() != slippageConfig.defaultSlippage
}
}
@@ -0,0 +1,26 @@
package io.novafoundation.nova.feature_swap_impl.presentation.options.di
import androidx.fragment.app.Fragment
import dagger.BindsInstance
import dagger.Subcomponent
import io.novafoundation.nova.common.di.scope.ScreenScope
import io.novafoundation.nova.feature_swap_impl.presentation.options.SwapOptionsFragment
@Subcomponent(
modules = [
SwapOptionsModule::class
]
)
@ScreenScope
interface SwapOptionsComponent {
@Subcomponent.Factory
interface Factory {
fun create(
@BindsInstance fragment: Fragment
): SwapOptionsComponent
}
fun inject(fragment: SwapOptionsFragment)
}
@@ -0,0 +1,60 @@
package io.novafoundation.nova.feature_swap_impl.presentation.options.di
import androidx.fragment.app.Fragment
import androidx.lifecycle.ViewModel
import androidx.lifecycle.ViewModelProvider
import dagger.Module
import dagger.Provides
import dagger.multibindings.IntoMap
import io.novafoundation.nova.common.di.viewmodel.ViewModelKey
import io.novafoundation.nova.common.di.viewmodel.ViewModelModule
import io.novafoundation.nova.common.resources.ResourceManager
import io.novafoundation.nova.common.view.bottomSheet.description.DescriptionBottomSheetLauncher
import io.novafoundation.nova.feature_swap_api.presentation.state.SwapSettingsStateProvider
import io.novafoundation.nova.feature_swap_impl.domain.interactor.SwapInteractor
import io.novafoundation.nova.feature_swap_impl.presentation.SwapRouter
import io.novafoundation.nova.feature_swap_impl.presentation.common.SlippageAlertMixinFactory
import io.novafoundation.nova.feature_swap_impl.presentation.common.fieldValidation.SlippageFieldValidatorFactory
import io.novafoundation.nova.feature_swap_impl.presentation.options.SwapOptionsViewModel
@Module(includes = [ViewModelModule::class])
class SwapOptionsModule {
@Provides
fun provideSlippageFieldValidatorFactory(
resourceManager: ResourceManager
): SlippageFieldValidatorFactory {
return SlippageFieldValidatorFactory(resourceManager)
}
@Provides
@IntoMap
@ViewModelKey(SwapOptionsViewModel::class)
fun provideViewModel(
swapRouter: SwapRouter,
resourceManager: ResourceManager,
swapSettingsStateProvider: SwapSettingsStateProvider,
slippageFieldValidatorFactory: SlippageFieldValidatorFactory,
swapInteractor: SwapInteractor,
descriptionBottomSheetLauncher: DescriptionBottomSheetLauncher,
slippageAlertMixinFactory: SlippageAlertMixinFactory
): ViewModel {
return SwapOptionsViewModel(
swapRouter,
resourceManager,
swapSettingsStateProvider,
slippageFieldValidatorFactory,
swapInteractor,
descriptionBottomSheetLauncher,
slippageAlertMixinFactory
)
}
@Provides
fun provideViewModelCreator(
fragment: Fragment,
viewModelFactory: ViewModelProvider.Factory
): SwapOptionsViewModel {
return ViewModelProvider(fragment, viewModelFactory).get(SwapOptionsViewModel::class.java)
}
}
@@ -0,0 +1,66 @@
package io.novafoundation.nova.feature_swap_impl.presentation.route
import androidx.recyclerview.widget.ConcatAdapter
import io.novafoundation.nova.common.base.BaseFragment
import io.novafoundation.nova.common.di.FeatureUtils
import io.novafoundation.nova.common.domain.onLoaded
import io.novafoundation.nova.common.domain.onNotLoaded
import io.novafoundation.nova.common.utils.makeGone
import io.novafoundation.nova.common.utils.makeVisible
import io.novafoundation.nova.feature_swap_api.di.SwapFeatureApi
import io.novafoundation.nova.feature_swap_impl.databinding.FragmentRouteBinding
import io.novafoundation.nova.feature_swap_impl.di.SwapFeatureComponent
import io.novafoundation.nova.feature_swap_impl.presentation.route.list.SwapRouteAdapter
import io.novafoundation.nova.feature_swap_impl.presentation.route.list.SwapRouteHeaderAdapter
import io.novafoundation.nova.feature_swap_impl.presentation.route.list.SwapRouteViewHolder
import io.novafoundation.nova.feature_swap_impl.presentation.route.list.TimelineItemDecoration
class SwapRouteFragment : BaseFragment<SwapRouteViewModel, FragmentRouteBinding>(), SwapRouteHeaderAdapter.Handler {
override fun createBinding() = FragmentRouteBinding.inflate(layoutInflater)
private lateinit var headerAdapter: SwapRouteHeaderAdapter
private lateinit var routeAdapter: SwapRouteAdapter
override fun initViews() {
binder.swapRouteContent.setHasFixedSize(true)
binder.swapRouteContent.itemAnimator = null
val timelineDecoration = TimelineItemDecoration(
context = requireContext(),
shouldDecorate = { it is SwapRouteViewHolder }
)
binder.swapRouteContent.addItemDecoration(timelineDecoration)
headerAdapter = SwapRouteHeaderAdapter(this)
routeAdapter = SwapRouteAdapter()
binder.swapRouteContent.adapter = ConcatAdapter(headerAdapter, routeAdapter)
}
override fun inject() {
FeatureUtils.getFeature<SwapFeatureComponent>(
requireContext(),
SwapFeatureApi::class.java
)
.swapRoute()
.create(this)
.inject(this)
}
override fun subscribe(viewModel: SwapRouteViewModel) {
viewModel.swapRoute.observe { routeState ->
routeState.onLoaded {
binder.swapRouteProgress.makeGone()
routeAdapter.submitList(it)
}.onNotLoaded {
binder.swapRouteProgress.makeVisible()
routeAdapter.submitList(emptyList())
}
}
}
override fun backClicked() {
viewModel.backClicked()
}
}
@@ -0,0 +1,95 @@
package io.novafoundation.nova.feature_swap_impl.presentation.route
import androidx.lifecycle.viewModelScope
import io.novafoundation.nova.common.base.BaseViewModel
import io.novafoundation.nova.common.presentation.AssetIconProvider
import io.novafoundation.nova.common.resources.ResourceManager
import io.novafoundation.nova.common.utils.withSafeLoading
import io.novafoundation.nova.feature_account_api.presenatation.chain.getAssetIconOrFallback
import io.novafoundation.nova.feature_swap_api.domain.model.AtomicOperationDisplayData
import io.novafoundation.nova.feature_swap_api.domain.model.SwapFee
import io.novafoundation.nova.feature_swap_impl.R
import io.novafoundation.nova.feature_swap_impl.domain.interactor.SwapInteractor
import io.novafoundation.nova.feature_swap_impl.presentation.SwapRouter
import io.novafoundation.nova.feature_swap_impl.presentation.common.state.SwapStateStoreProvider
import io.novafoundation.nova.feature_swap_impl.presentation.common.state.stateFlow
import io.novafoundation.nova.feature_swap_impl.presentation.route.model.SwapRouteItemModel
import io.novafoundation.nova.feature_swap_impl.presentation.route.view.TokenAmountModel
import io.novafoundation.nova.feature_wallet_api.domain.model.FiatAmount
import io.novafoundation.nova.feature_wallet_api.presentation.formatters.formatAsCurrency
import io.novafoundation.nova.runtime.multiNetwork.ChainRegistry
import io.novafoundation.nova.runtime.multiNetwork.asset
import io.novafoundation.nova.runtime.multiNetwork.chainWithAsset
import kotlinx.coroutines.flow.map
class SwapRouteViewModel(
private val swapInteractor: SwapInteractor,
private val swapStateStoreProvider: SwapStateStoreProvider,
private val chainRegistry: ChainRegistry,
private val assetIconProvider: AssetIconProvider,
private val router: SwapRouter,
private val resourceManager: ResourceManager
) : BaseViewModel() {
val swapRoute = swapStateStoreProvider.stateFlow(viewModelScope)
.map { it.fee.toSwapRouteUi() }
.withSafeLoading()
.shareInBackground()
private suspend fun SwapFee.toSwapRouteUi(): List<SwapRouteItemModel> {
val pricedFees = swapInteractor.calculateSegmentFiatPrices(this)
return pricedFees.zip(segments).mapIndexed { index, (pricedFee, segment) ->
val displayData = segment.operation.constructDisplayData()
displayData.toUi(pricedFee, id = index)
}
}
private suspend fun AtomicOperationDisplayData.toUi(
fee: FiatAmount,
id: Int
): SwapRouteItemModel {
val formattedFee = fee.formatAsCurrency()
val feeWithLabel = resourceManager.getString(R.string.common_fee_with_label, formattedFee)
return when (this) {
is AtomicOperationDisplayData.Swap -> toUi(feeWithLabel, id)
is AtomicOperationDisplayData.Transfer -> toUi(feeWithLabel, id)
}
}
private suspend fun AtomicOperationDisplayData.Transfer.toUi(fee: String, id: Int): SwapRouteItemModel.Transfer {
val (chainFrom, assetFrom) = chainRegistry.chainWithAsset(from)
val assetFromIcon = assetIconProvider.getAssetIconOrFallback(assetFrom)
val chainTo = chainRegistry.getChain(to.chainId)
return SwapRouteItemModel.Transfer(
id = id,
amount = TokenAmountModel.from(assetFrom, assetFromIcon, amount),
fee = fee,
originChainName = chainFrom.name,
destinationChainName = chainTo.name
)
}
private suspend fun AtomicOperationDisplayData.Swap.toUi(fee: String, id: Int): SwapRouteItemModel.Swap {
val (chainFrom, assetFrom) = chainRegistry.chainWithAsset(from.chainAssetId)
val assetFromIcon = assetIconProvider.getAssetIconOrFallback(assetFrom)
val assetTo = chainRegistry.asset(to.chainAssetId)
val assetToIcon = assetIconProvider.getAssetIconOrFallback(assetTo)
return SwapRouteItemModel.Swap(
id = id,
amountFrom = TokenAmountModel.from(assetFrom, assetFromIcon, from.amount),
amountTo = TokenAmountModel.from(assetTo, assetToIcon, to.amount),
fee = fee,
chain = chainFrom.name
)
}
fun backClicked() {
router.back()
}
}
@@ -0,0 +1,26 @@
package io.novafoundation.nova.feature_swap_impl.presentation.route.di
import androidx.fragment.app.Fragment
import dagger.BindsInstance
import dagger.Subcomponent
import io.novafoundation.nova.common.di.scope.ScreenScope
import io.novafoundation.nova.feature_swap_impl.presentation.route.SwapRouteFragment
@Subcomponent(
modules = [
SwapRouteModule::class
]
)
@ScreenScope
interface SwapRouteComponent {
@Subcomponent.Factory
interface Factory {
fun create(
@BindsInstance fragment: Fragment,
): SwapRouteComponent
}
fun inject(fragment: SwapRouteFragment)
}
@@ -0,0 +1,50 @@
package io.novafoundation.nova.feature_swap_impl.presentation.route.di
import androidx.fragment.app.Fragment
import androidx.lifecycle.ViewModel
import androidx.lifecycle.ViewModelProvider
import dagger.Module
import dagger.Provides
import dagger.multibindings.IntoMap
import io.novafoundation.nova.common.di.viewmodel.ViewModelKey
import io.novafoundation.nova.common.di.viewmodel.ViewModelModule
import io.novafoundation.nova.common.presentation.AssetIconProvider
import io.novafoundation.nova.common.resources.ResourceManager
import io.novafoundation.nova.feature_swap_impl.domain.interactor.SwapInteractor
import io.novafoundation.nova.feature_swap_impl.presentation.SwapRouter
import io.novafoundation.nova.feature_swap_impl.presentation.common.state.SwapStateStoreProvider
import io.novafoundation.nova.feature_swap_impl.presentation.route.SwapRouteViewModel
import io.novafoundation.nova.runtime.multiNetwork.ChainRegistry
@Module(includes = [ViewModelModule::class])
class SwapRouteModule {
@Provides
@IntoMap
@ViewModelKey(SwapRouteViewModel::class)
fun provideViewModel(
swapInteractor: SwapInteractor,
swapStateStoreProvider: SwapStateStoreProvider,
chainRegistry: ChainRegistry,
assetIconProvider: AssetIconProvider,
resourceManager: ResourceManager,
router: SwapRouter,
): ViewModel {
return SwapRouteViewModel(
swapInteractor = swapInteractor,
swapStateStoreProvider = swapStateStoreProvider,
chainRegistry = chainRegistry,
assetIconProvider = assetIconProvider,
router = router,
resourceManager = resourceManager
)
}
@Provides
fun provideViewModelCreator(
fragment: Fragment,
viewModelFactory: ViewModelProvider.Factory
): SwapRouteViewModel {
return ViewModelProvider(fragment, viewModelFactory).get(SwapRouteViewModel::class.java)
}
}
@@ -0,0 +1,82 @@
package io.novafoundation.nova.feature_swap_impl.presentation.route.list
import android.view.View
import android.view.ViewGroup
import androidx.recyclerview.widget.DiffUtil
import io.novafoundation.nova.common.list.BaseListAdapter
import io.novafoundation.nova.common.list.BaseViewHolder
import io.novafoundation.nova.common.utils.inflater
import io.novafoundation.nova.common.view.shape.getRoundedCornerDrawable
import io.novafoundation.nova.feature_swap_impl.R
import io.novafoundation.nova.feature_swap_impl.databinding.ItemRouteSwapBinding
import io.novafoundation.nova.feature_swap_impl.databinding.ItemRouteTransferBinding
import io.novafoundation.nova.feature_swap_impl.presentation.route.model.SwapRouteItemModel
class SwapRouteAdapter : BaseListAdapter<SwapRouteItemModel, SwapRouteViewHolder>(SwapRouteDiffCallback()) {
override fun onBindViewHolder(holder: SwapRouteViewHolder, position: Int) {
when (val item = getItem(position)) {
is SwapRouteItemModel.Swap -> (holder as SwapRouteSwapViewHolder).bind(item)
is SwapRouteItemModel.Transfer -> (holder as SwapRouteTransferViewHolder).bind(item)
}
}
override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): SwapRouteViewHolder {
return when (viewType) {
R.layout.item_route_swap -> SwapRouteSwapViewHolder(ItemRouteSwapBinding.inflate(parent.inflater(), parent, false))
R.layout.item_route_transfer -> SwapRouteTransferViewHolder(ItemRouteTransferBinding.inflate(parent.inflater(), parent, false))
else -> error("Unknown viewType: $viewType")
}
}
override fun getItemViewType(position: Int): Int {
return when (getItem(position)) {
is SwapRouteItemModel.Swap -> R.layout.item_route_swap
is SwapRouteItemModel.Transfer -> R.layout.item_route_transfer
}
}
}
sealed class SwapRouteViewHolder(itemView: View) : BaseViewHolder(itemView) {
init {
with(itemView) {
background = context.getRoundedCornerDrawable(R.color.input_background)
}
}
}
class SwapRouteTransferViewHolder(private val binder: ItemRouteTransferBinding) : SwapRouteViewHolder(binder.root) {
fun bind(model: SwapRouteItemModel.Transfer) = with(binder) {
itemRouteTransferAmount.setModel(model.amount)
itemRouteTransferFee.text = model.fee
itemRouteTransferFrom.text = model.originChainName
itemRouteTransferTo.text = model.destinationChainName
}
override fun unbind() {}
}
class SwapRouteSwapViewHolder(private val binder: ItemRouteSwapBinding) : SwapRouteViewHolder(binder.root) {
fun bind(model: SwapRouteItemModel.Swap) = with(binder) {
itemRouteSwapAmountFrom.setModel(model.amountFrom)
itemRouteSwapAmountTo.setModel(model.amountTo)
itemRouteSwapFee.text = model.fee
itemRouteSwapChain.text = model.chain
}
override fun unbind() {}
}
private class SwapRouteDiffCallback : DiffUtil.ItemCallback<SwapRouteItemModel>() {
override fun areItemsTheSame(oldItem: SwapRouteItemModel, newItem: SwapRouteItemModel): Boolean {
return oldItem.id == newItem.id
}
override fun areContentsTheSame(oldItem: SwapRouteItemModel, newItem: SwapRouteItemModel): Boolean {
return oldItem == newItem
}
}

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