mirror of
https://github.com/pezkuwichain/pezkuwi-wallet-android.git
synced 2026-04-26 01:37:59 +00:00
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:
@@ -0,0 +1,4 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
|
||||
|
||||
</manifest>
|
||||
+57
@@ -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,
|
||||
)
|
||||
+5
@@ -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
|
||||
+374
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
+358
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
+607
@@ -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,
|
||||
)
|
||||
}
|
||||
+50
@@ -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
|
||||
}
|
||||
}
|
||||
+59
@@ -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"
|
||||
}
|
||||
}
|
||||
}
|
||||
+136
@@ -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
|
||||
)
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
+13
@@ -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"
|
||||
}
|
||||
}
|
||||
+27
@@ -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) },
|
||||
)
|
||||
+67
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
+56
@@ -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"
|
||||
}
|
||||
}
|
||||
}
|
||||
+65
@@ -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()
|
||||
}
|
||||
+60
@@ -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
|
||||
)
|
||||
}
|
||||
}
|
||||
+68
@@ -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
|
||||
}
|
||||
+173
@@ -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
|
||||
}
|
||||
+37
@@ -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)
|
||||
}
|
||||
}
|
||||
+248
@@ -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()
|
||||
}
|
||||
}
|
||||
+38
@@ -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
|
||||
)
|
||||
}
|
||||
}
|
||||
+27
@@ -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
|
||||
)
|
||||
}
|
||||
}
|
||||
+91
@@ -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
|
||||
)
|
||||
}
|
||||
}
|
||||
+38
@@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
+33
@@ -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)
|
||||
}
|
||||
}
|
||||
+180
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
+9
@@ -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
|
||||
)
|
||||
+960
@@ -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
|
||||
}
|
||||
+31
@@ -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)
|
||||
}
|
||||
+88
@@ -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()
|
||||
}
|
||||
+126
@@ -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 }
|
||||
)
|
||||
+34
@@ -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!!
|
||||
}
|
||||
}
|
||||
+93
@@ -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
|
||||
}
|
||||
}
|
||||
+41
@@ -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()
|
||||
}
|
||||
}
|
||||
+34
@@ -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()
|
||||
}
|
||||
}
|
||||
+22
@@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
+62
@@ -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
|
||||
)
|
||||
)
|
||||
}
|
||||
+46
@@ -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))
|
||||
+49
@@ -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)
|
||||
)
|
||||
+22
@@ -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()
|
||||
}
|
||||
}
|
||||
+40
@@ -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
|
||||
}
|
||||
}
|
||||
+25
@@ -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()
|
||||
}
|
||||
}
|
||||
+36
@@ -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)
|
||||
)
|
||||
+28
@@ -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()
|
||||
}
|
||||
+45
@@ -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
|
||||
}
|
||||
}
|
||||
+51
@@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
+16
@@ -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"
|
||||
}
|
||||
}
|
||||
+89
@@ -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))
|
||||
}
|
||||
}
|
||||
+13
@@ -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,
|
||||
)
|
||||
+28
@@ -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
|
||||
)
|
||||
}
|
||||
+30
@@ -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)
|
||||
}
|
||||
}
|
||||
+21
@@ -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
|
||||
}
|
||||
}
|
||||
+36
@@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
+49
@@ -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
|
||||
}
|
||||
}
|
||||
+67
@@ -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 }
|
||||
}
|
||||
}
|
||||
+45
@@ -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!!
|
||||
}
|
||||
}
|
||||
+49
@@ -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 }
|
||||
}
|
||||
}
|
||||
+10
@@ -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?>
|
||||
+41
@@ -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)
|
||||
}
|
||||
}
|
||||
+105
@@ -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)
|
||||
}
|
||||
}
|
||||
+83
@@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
+26
@@ -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))
|
||||
}
|
||||
}
|
||||
}
|
||||
+11
@@ -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,
|
||||
)
|
||||
+43
@@ -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()
|
||||
}
|
||||
}
|
||||
+41
@@ -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)
|
||||
}
|
||||
+103
@@ -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()
|
||||
}
|
||||
}
|
||||
}
|
||||
+68
@@ -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)
|
||||
}
|
||||
}
|
||||
+397
@@ -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)
|
||||
}
|
||||
}
|
||||
+26
@@ -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)
|
||||
}
|
||||
+89
@@ -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)
|
||||
}
|
||||
}
|
||||
+221
@@ -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
|
||||
}
|
||||
}
|
||||
+152
@@ -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)
|
||||
}
|
||||
}
|
||||
+275
@@ -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
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
+26
@@ -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)
|
||||
}
|
||||
+62
@@ -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)
|
||||
}
|
||||
}
|
||||
+12
@@ -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?,
|
||||
)
|
||||
+16
@@ -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()
|
||||
}
|
||||
+105
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
+113
@@ -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)
|
||||
)
|
||||
}
|
||||
}
|
||||
+26
@@ -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)
|
||||
}
|
||||
+47
@@ -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)
|
||||
}
|
||||
}
|
||||
+14
@@ -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)
|
||||
}
|
||||
+25
@@ -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
|
||||
}
|
||||
}
|
||||
+100
@@ -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)
|
||||
}
|
||||
}
|
||||
+737
@@ -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 }
|
||||
}
|
||||
+243
@@ -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 = { }
|
||||
)
|
||||
)
|
||||
)
|
||||
}
|
||||
+28
@@ -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)
|
||||
}
|
||||
+135
@@ -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)
|
||||
}
|
||||
}
|
||||
+27
@@ -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()
|
||||
}
|
||||
}
|
||||
}
|
||||
+100
@@ -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,
|
||||
)
|
||||
}
|
||||
}
|
||||
+16
@@ -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)
|
||||
}
|
||||
+42
@@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
+53
@@ -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) }
|
||||
}
|
||||
}
|
||||
+150
@@ -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
|
||||
}
|
||||
}
|
||||
+26
@@ -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)
|
||||
}
|
||||
+60
@@ -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)
|
||||
}
|
||||
}
|
||||
+66
@@ -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()
|
||||
}
|
||||
}
|
||||
+95
@@ -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()
|
||||
}
|
||||
}
|
||||
+26
@@ -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)
|
||||
}
|
||||
+50
@@ -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)
|
||||
}
|
||||
}
|
||||
+82
@@ -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
Reference in New Issue
Block a user