Initial commit: Pezkuwi Wallet Android

Security hardened release:
- Code obfuscation enabled (minifyEnabled=true, shrinkResources=true)
- Sensitive files excluded (google-services.json, keystores)
- Branch.io key moved to BuildConfig placeholder
- Updated dependencies: OkHttp 4.12.0, Gson 2.10.1, BouncyCastle 1.77
- Comprehensive ProGuard rules for crypto wallet
- Navigation 2.7.7, Lifecycle 2.7.0, ConstraintLayout 2.1.4
This commit is contained in:
2026-02-12 05:19:41 +03:00
commit a294aa1a6b
7687 changed files with 441811 additions and 0 deletions
+1
View File
@@ -0,0 +1 @@
/build
+31
View File
@@ -0,0 +1,31 @@
apply plugin: 'kotlin-parcelize'
android {
defaultConfig {
namespace 'io.novafoundation.nova.feature_swap_api'
}
buildFeatures {
viewBinding true
}
}
dependencies {
implementation coroutinesDep
implementation substrateSdkDep
implementation daggerDep
implementation project(':runtime')
implementation project(':common')
implementation project(":feature-swap-core")
api project(":feature-wallet-api")
api project(":feature-account-api")
api project(':core-api')
}
View File
+21
View File
@@ -0,0 +1,21 @@
# Add project specific ProGuard rules here.
# You can control the set of applied configuration files using the
# proguardFiles setting in build.gradle.
#
# For more details, see
# http://developer.android.com/guide/developing/tools/proguard.html
# If your project uses WebView with JS, uncomment the following
# and specify the fully qualified class name to the JavaScript interface
# class:
#-keepclassmembers class fqcn.of.javascript.interface.for.webview {
# public *;
#}
# Uncomment this to preserve the line number information for
# debugging stack traces.
#-keepattributes SourceFile,LineNumberTable
# If you keep the line number information, uncomment this to
# hide the original source file name.
#-renamesourcefileattribute SourceFile
@@ -0,0 +1,4 @@
<?xml version="1.0" encoding="utf-8"?>
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
</manifest>
@@ -0,0 +1,20 @@
package io.novafoundation.nova.feature_swap_api.di
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
interface SwapFeatureApi {
val swapService: SwapService
val swapSettingsStateProvider: SwapSettingsStateProvider
val swapAvailabilityInteractor: SwapAvailabilityInteractor
val swapRateFormatter: SwapRateFormatter
val swapFlowScopeAggregator: SwapFlowScopeAggregator
}
@@ -0,0 +1,16 @@
package io.novafoundation.nova.feature_swap_api.domain.interactor
import io.novafoundation.nova.runtime.multiNetwork.chain.model.Chain
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.flow.Flow
interface SwapAvailabilityInteractor {
suspend fun sync(coroutineScope: CoroutineScope)
suspend fun warmUpCommonlyUsedChains(computationScope: CoroutineScope)
fun anySwapAvailableFlow(): Flow<Boolean>
suspend fun swapAvailableFlow(asset: Chain.Asset, coroutineScope: CoroutineScope): Flow<Boolean>
}
@@ -0,0 +1,19 @@
package io.novafoundation.nova.feature_swap_api.domain.model
import io.novafoundation.nova.feature_wallet_api.data.network.blockhain.types.Balance
import io.novafoundation.nova.feature_wallet_api.domain.model.ChainAssetIdWithAmount
import io.novafoundation.nova.runtime.multiNetwork.chain.model.FullChainAssetId
sealed class AtomicOperationDisplayData {
data class Transfer(
val from: FullChainAssetId,
val to: FullChainAssetId,
val amount: Balance
) : AtomicOperationDisplayData()
data class Swap(
val from: ChainAssetIdWithAmount,
val to: ChainAssetIdWithAmount,
) : AtomicOperationDisplayData()
}
@@ -0,0 +1,30 @@
package io.novafoundation.nova.feature_swap_api.domain.model
import io.novafoundation.nova.feature_account_api.data.model.FeeBase
import io.novafoundation.nova.feature_swap_api.domain.model.AtomicOperationFeeDisplayData.SwapFeeComponentDisplay
import io.novafoundation.nova.feature_swap_api.domain.model.AtomicOperationFeeDisplayData.SwapFeeType
class AtomicOperationFeeDisplayData(
val components: List<SwapFeeComponentDisplay>
) {
class SwapFeeComponentDisplay(
val fees: List<FeeBase>,
val type: SwapFeeType
) {
companion object;
}
enum class SwapFeeType {
NETWORK, CROSS_CHAIN
}
}
fun SwapFeeComponentDisplay.Companion.network(vararg fee: FeeBase): SwapFeeComponentDisplay {
return SwapFeeComponentDisplay(fee.toList(), SwapFeeType.NETWORK)
}
fun SwapFeeComponentDisplay.Companion.crossChain(vararg fee: FeeBase): SwapFeeComponentDisplay {
return SwapFeeComponentDisplay(fee.toList(), SwapFeeType.CROSS_CHAIN)
}
@@ -0,0 +1,87 @@
package io.novafoundation.nova.feature_swap_api.domain.model
import io.novafoundation.nova.feature_account_api.data.fee.FeePaymentCurrency
import io.novafoundation.nova.feature_account_api.data.model.FeeBase
import io.novafoundation.nova.feature_account_api.data.model.totalAmount
import io.novafoundation.nova.feature_account_api.data.model.totalPlanksEnsuringAsset
import io.novafoundation.nova.feature_account_api.data.signer.SubmissionHierarchy
import io.novafoundation.nova.feature_swap_api.domain.model.fee.AtomicSwapOperationFee
import io.novafoundation.nova.feature_wallet_api.data.network.blockhain.types.Balance
import io.novafoundation.nova.runtime.ext.fullId
import io.novafoundation.nova.runtime.multiNetwork.chain.model.Chain
import io.novafoundation.nova.runtime.multiNetwork.chain.model.FullChainAssetId
interface AtomicSwapOperation {
val estimatedSwapLimit: SwapLimit
val assetIn: FullChainAssetId
val assetOut: FullChainAssetId
suspend fun constructDisplayData(): AtomicOperationDisplayData
suspend fun estimateFee(): AtomicSwapOperationFee
/**
* Calculates how much of assetIn (of the current segment) is needed to buy given [extraOutAmount] of asset out (of the current segment)
* Used to estimate how much extra amount of assetIn to add to the user input to accommodate future segment fees
*/
suspend fun requiredAmountInToGetAmountOut(extraOutAmount: Balance): Balance
/**
* Additional amount that max amount calculation should leave aside for the **first** operation in the swap
* One example is Existential Deposit in case operation executes in "keep alive" manner
*/
suspend fun additionalMaxAmountDeduction(): SwapMaxAdditionalAmountDeduction
suspend fun execute(args: AtomicSwapOperationSubmissionArgs): Result<SwapExecutionCorrection>
suspend fun submit(args: AtomicSwapOperationSubmissionArgs): Result<SwapSubmissionResult>
}
class AtomicSwapOperationSubmissionArgs(
val actualSwapLimit: SwapLimit,
)
class AtomicSwapOperationArgs(
val estimatedSwapLimit: SwapLimit,
val feePaymentCurrency: FeePaymentCurrency,
)
fun AtomicSwapOperationFee.amountToLeaveOnOriginToPayTxFees(): Balance {
val submissionAsset = submissionFee.asset
return submissionFee.amount + postSubmissionFees.paidByAccount.totalAmount(submissionAsset, submissionFee.submissionOrigin.executingAccount)
}
fun AtomicSwapOperationFee.totalFeeEnsuringSubmissionAsset(): Balance {
val postSubmissionFeesByAccount = postSubmissionFees.paidByAccount.totalPlanksEnsuringAsset(submissionFee.asset)
val postSubmissionFeesFromHolding = postSubmissionFees.paidByAccount.totalPlanksEnsuringAsset(submissionFee.asset)
return submissionFee.amount + postSubmissionFeesByAccount + postSubmissionFeesFromHolding
}
/**
* Collects all [FeeBase] instances from fee components
*/
fun AtomicSwapOperationFee.allBasicFees(): List<FeeBase> {
return buildList {
add(submissionFee)
postSubmissionFees.paidByAccount.onEach(::add)
postSubmissionFees.paidFromAmount.onEach(::add)
}
}
fun AtomicSwapOperationFee.allFeeAssets(): List<Chain.Asset> {
return allBasicFees()
.map { it.asset }
.distinctBy { it.fullId }
}
class SwapExecutionCorrection(
val actualReceivedAmount: Balance
)
class SwapSubmissionResult(
val submissionHierarchy: SubmissionHierarchy
)
@@ -0,0 +1,23 @@
package io.novafoundation.nova.feature_swap_api.domain.model
import io.novafoundation.nova.runtime.multiNetwork.chain.model.ChainId
import java.math.BigDecimal
import kotlin.time.Duration
interface AtomicSwapOperationPrototype {
val fromChain: ChainId
/**
* Roughly estimate fees for the current operation in native asset
* Implementations should favour speed instead of precision as this is called for each quoting action
*/
suspend fun roughlyEstimateNativeFee(usdConverter: UsdConverter): BigDecimal
suspend fun maximumExecutionTime(): Duration
}
interface UsdConverter {
suspend fun nativeAssetEquivalentOf(usdAmount: Double): BigDecimal
}
@@ -0,0 +1,22 @@
package io.novafoundation.nova.feature_swap_api.domain.model
import io.novafoundation.nova.feature_account_api.data.model.FeeBase
import io.novafoundation.nova.feature_account_api.data.model.SubmissionFee
interface WithDebugLabel {
val debugLabel: String
}
class SubmissionFeeWithLabel(
val fee: SubmissionFee,
override val debugLabel: String = "Submission"
) : WithDebugLabel, SubmissionFee by fee
fun SubmissionFeeWithLabel(fee: SubmissionFee?, debugLabel: String): SubmissionFeeWithLabel? {
return fee?.let { SubmissionFeeWithLabel(it, debugLabel) }
}
class FeeWithLabel(
val fee: FeeBase,
override val debugLabel: String
) : WithDebugLabel, FeeBase by fee
@@ -0,0 +1,3 @@
package io.novafoundation.nova.feature_swap_api.domain.model
typealias ReQuoteTrigger = Unit
@@ -0,0 +1,28 @@
package io.novafoundation.nova.feature_swap_api.domain.model
import io.novafoundation.nova.common.utils.Fraction
import io.novafoundation.nova.common.utils.Fraction.Companion.percents
class SlippageConfig(
val defaultSlippage: Fraction,
val slippageTips: List<Fraction>,
val minAvailableSlippage: Fraction,
val maxAvailableSlippage: Fraction,
val smallSlippage: Fraction,
val bigSlippage: Fraction
) {
companion object {
fun default(): SlippageConfig {
return SlippageConfig(
defaultSlippage = 0.5.percents,
slippageTips = listOf(0.1.percents, 0.5.percents, 1.0.percents),
minAvailableSlippage = 0.01.percents,
maxAvailableSlippage = 50.0.percents,
smallSlippage = 0.05.percents,
bigSlippage = 1.0.percents
)
}
}
}
@@ -0,0 +1,17 @@
package io.novafoundation.nova.feature_swap_api.domain.model
import io.novafoundation.nova.common.utils.sum
import kotlin.time.Duration
class SwapExecutionEstimate(
val atomicOperationsEstimates: List<Duration>,
val additionalBuffer: Duration
)
fun SwapExecutionEstimate.totalTime(): Duration {
return remainingTimeWhenExecuting(stepIndex = 0)
}
fun SwapExecutionEstimate.remainingTimeWhenExecuting(stepIndex: Int): Duration {
return atomicOperationsEstimates.drop(stepIndex).sum() + additionalBuffer
}
@@ -0,0 +1,69 @@
package io.novafoundation.nova.feature_swap_api.domain.model
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.model.getAmount
import io.novafoundation.nova.feature_account_api.data.model.totalAmount
import io.novafoundation.nova.feature_swap_api.domain.model.fee.AtomicSwapOperationFee
import io.novafoundation.nova.feature_wallet_api.data.network.blockhain.types.Balance
import io.novafoundation.nova.feature_account_api.presenatation.mixin.addressInput.maxAction.MaxAvailableDeduction
import io.novafoundation.nova.runtime.ext.fullId
import io.novafoundation.nova.runtime.multiNetwork.chain.model.Chain
class SwapFee(
val segments: List<SwapSegment>,
/**
* Fees for second and subsequent segments converted to assetIn
*/
val intermediateSegmentFeesInAssetIn: FeeBase,
/**
* Additional deductions from max amount of asset in that are not directly caused by fees
*/
val additionalMaxAmountDeduction: SwapMaxAdditionalAmountDeduction,
) : MaxAvailableDeduction {
data class SwapSegment(val fee: AtomicSwapOperationFee, val operation: AtomicSwapOperation)
val firstSegmentFee = segments.first().fee
val initialSubmissionFee = firstSegmentFee.submissionFee
private val initialPostSubmissionFees = firstSegmentFee.postSubmissionFees
private val assetIn = intermediateSegmentFeesInAssetIn.asset
// Always in asset in
val additionalAmountForSwap = additionalAmountForSwap()
override fun maxAmountDeductionFor(amountAsset: Chain.Asset): Balance {
return totalFeeAmount(amountAsset) + additionalMaxAmountDeduction(amountAsset)
}
fun allBasicFees(): List<FeeBase> {
return segments.flatMap { it.fee.allBasicFees() }
}
fun totalFeeAmount(amountAsset: Chain.Asset): Balance {
val executingAccount = initialSubmissionFee.submissionOrigin.executingAccount
val submissionFeeAmount = initialSubmissionFee.getAmount(amountAsset, executingAccount)
val additionalFeesAmount = initialPostSubmissionFees.paidByAccount.totalAmount(amountAsset, executingAccount)
return submissionFeeAmount + additionalFeesAmount + additionalAmountForSwap.getAmount(amountAsset)
}
private fun additionalMaxAmountDeduction(amountAsset: Chain.Asset): Balance {
// TODO deducting `fromCountedTowardsEd` from max amount is over-conservative
// Ideally we should deduct max((fromCountedTowardsEd - (countedTowardsEd - transferable)) , 0)
return if (amountAsset.fullId == assetIn.fullId) additionalMaxAmountDeduction.fromCountedTowardsEd else Balance.ZERO
}
private fun additionalAmountForSwap(): FeeBase {
val amountTakenFromAssetIn = initialPostSubmissionFees.paidFromAmount.totalAmount(assetIn)
val totalFutureFeeInAssetIn = amountTakenFromAssetIn + intermediateSegmentFeesInAssetIn.amount
return SubstrateFeeBase(totalFutureFeeInAssetIn, assetIn)
}
}
@@ -0,0 +1,69 @@
package io.novafoundation.nova.feature_swap_api.domain.model
import io.novafoundation.nova.common.utils.graph.Graph
import io.novafoundation.nova.feature_swap_core_api.data.primitive.model.QuotableEdge
import io.novafoundation.nova.runtime.multiNetwork.chain.model.FullChainAssetId
interface SwapGraphEdge : QuotableEdge {
/**
* Begin a fully-constructed, ready to submit operation
*/
suspend fun beginOperation(args: AtomicSwapOperationArgs): AtomicSwapOperation
/**
* Append current swap edge execution to the existing transaction
* Return null if it is not possible, indicating that the new transaction should be initiated to handle this edge via
* [beginOperation]
*/
suspend fun appendToOperation(currentTransaction: AtomicSwapOperation, args: AtomicSwapOperationArgs): AtomicSwapOperation?
/**
* Begin a operation prototype that should reflect similar structure to [beginOperation] and [appendToOperation] but is limited to available functionality
* Used during quoting to construct the operations array when not all parameters are still known
*/
suspend fun beginOperationPrototype(): AtomicSwapOperationPrototype
/**
* Append current swap edge execution to the existing transaction prototype
* Return null if it is not possible, indicating that the new transaction should be initiated to handle this edge via
* [beginOperationPrototype]
*/
suspend fun appendToOperationPrototype(currentTransaction: AtomicSwapOperationPrototype): AtomicSwapOperationPrototype?
/**
* Debug label to describe this edge for logging
*/
suspend fun debugLabel(): String
/**
* Whether this Edge fee check should be skipped when adding to after a specified [predecessor]
* The main purpose is to mirror the behavior of [appendToOperation] - multiple segments appended together
* most likely will only use fee configuration from the first segment in the batch
* Note that returning true here means that [canPayNonNativeFeesInIntermediatePosition] wont be called and checked
*/
fun predecessorHandlesFees(predecessor: SwapGraphEdge): Boolean
/**
* This indicates whether this segment can be appended to the previous one to form a single transaction
* It defaults to [predecessorHandlesFees] since ultimately they do the same thing, just under different name
*/
fun canAppendToPredecessor(predecessor: SwapGraphEdge): Boolean {
return predecessorHandlesFees(predecessor)
}
/**
* Can be used to define additional restrictions on top of default one, "is able to pay submission fee on origin"
* This will only be called for intermediate hops for non-utility assets since other cases are always payable
*/
suspend fun canPayNonNativeFeesInIntermediatePosition(): Boolean
/**
* Determines whether it is possible to spend whole asset-in to receive asset-out
* This has to be true for edge to be considered a valid intermediate segment
* since we don't want dust leftovers across intermediate segments
*/
suspend fun canTransferOutWholeAccountBalance(): Boolean
}
typealias SwapGraph = Graph<FullChainAssetId, SwapGraphEdge>
@@ -0,0 +1,185 @@
package io.novafoundation.nova.feature_swap_api.domain.model
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.divideToDecimal
import io.novafoundation.nova.common.utils.isZero
import io.novafoundation.nova.feature_account_api.data.fee.FeePaymentCurrency
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 java.math.BigDecimal
sealed class SwapLimit {
companion object;
data class SpecifiedIn(
val amountIn: Balance,
val amountOutQuote: Balance,
val amountOutMin: Balance
) : SwapLimit()
data class SpecifiedOut(
val amountOut: Balance,
val amountInQuote: Balance,
val amountInMax: Balance
) : SwapLimit()
}
val SwapLimit.swapDirection: SwapDirection
get() = when (this) {
is SwapLimit.SpecifiedIn -> SwapDirection.SPECIFIED_IN
is SwapLimit.SpecifiedOut -> SwapDirection.SPECIFIED_OUT
}
val SwapLimit.quotedAmount: Balance
get() = when (this) {
is SwapLimit.SpecifiedIn -> amountIn
is SwapLimit.SpecifiedOut -> amountOut
}
val SwapLimit.estimatedAmountIn: Balance
get() = when (this) {
is SwapLimit.SpecifiedIn -> amountIn
is SwapLimit.SpecifiedOut -> amountInQuote
}
val SwapLimit.amountOutMin: Balance
get() = when (this) {
is SwapLimit.SpecifiedIn -> amountOutMin
is SwapLimit.SpecifiedOut -> amountOut
}
val SwapLimit.estimatedAmountOut: Balance
get() = when (this) {
is SwapLimit.SpecifiedIn -> amountOutQuote
is SwapLimit.SpecifiedOut -> amountOut
}
/**
* Adjusts SwapLimit to the [newAmountIn] based on the quoted swap rate
* This is only suitable for small changes amount in, as it implicitly assumes the swap rate stays the same
*/
fun SwapLimit.replaceAmountIn(newAmountIn: Balance, shouldReplaceBuyWithSell: Boolean): SwapLimit {
return when (this) {
is SwapLimit.SpecifiedIn -> updateInAmount(newAmountIn)
is SwapLimit.SpecifiedOut -> {
if (shouldReplaceBuyWithSell) {
updateInAmountChangingToSell(newAmountIn)
} else {
updateInAmount(newAmountIn)
}
}
}
}
fun SwapLimit.Companion.createAggregated(firstLimit: SwapLimit, lastLimit: SwapLimit): SwapLimit {
return when (firstLimit) {
is SwapLimit.SpecifiedIn -> {
require(lastLimit is SwapLimit.SpecifiedIn)
SwapLimit.SpecifiedIn(
amountIn = firstLimit.amountIn,
amountOutQuote = lastLimit.amountOutQuote,
amountOutMin = lastLimit.amountOutMin
)
}
is SwapLimit.SpecifiedOut -> {
require(lastLimit is SwapLimit.SpecifiedOut)
SwapLimit.SpecifiedOut(
amountOut = lastLimit.amountOut,
amountInQuote = firstLimit.amountInQuote,
amountInMax = firstLimit.amountInMax
)
}
}
}
private fun SwapLimit.SpecifiedOut.updateInAmountChangingToSell(newAmountIn: Balance): SwapLimit {
val slippage = slippage()
val inferredQuotedBalance = replacedInQuoteAmount(newAmountIn, amountOut)
return SpecifiedIn(amount = newAmountIn, slippage, quotedBalance = inferredQuotedBalance)
}
private fun SwapLimit.SpecifiedOut.slippage(): Fraction {
if (amountInQuote.isZero) return Fraction.ZERO
val slippageAsFraction = (amountInMax.divideToDecimal(amountInQuote) - BigDecimal.ONE).atLeastZero()
return slippageAsFraction.fractions
}
private fun SwapLimit.SpecifiedIn.replaceInMultiplier(amount: Balance): BigDecimal {
return amount.divideToDecimal(amountIn)
}
private fun SwapLimit.SpecifiedIn.replacingInAmount(newInAmount: Balance, replacingAmount: Balance): Balance {
return (replaceInMultiplier(replacingAmount) * newInAmount.toBigDecimal()).toBigInteger()
}
private fun SwapLimit.SpecifiedIn.updateInAmount(newAmountIn: Balance): SwapLimit.SpecifiedIn {
return SwapLimit.SpecifiedIn(
amountIn = newAmountIn,
amountOutQuote = replacingInAmount(newAmountIn, replacingAmount = amountOutQuote),
amountOutMin = replacingInAmount(newAmountIn, replacingAmount = amountOutMin)
)
}
private fun SwapLimit.SpecifiedOut.replaceInQuoteMultiplier(amount: Balance): BigDecimal {
return amount.divideToDecimal(amountInQuote)
}
private fun SwapLimit.SpecifiedOut.replacedInQuoteAmount(newInQuoteAmount: Balance, replacingAmount: Balance): Balance {
return (replaceInQuoteMultiplier(replacingAmount) * newInQuoteAmount.toBigDecimal()).toBigInteger()
}
private fun SwapLimit.SpecifiedOut.updateInAmount(newAmountInQuote: Balance): SwapLimit.SpecifiedOut {
return SwapLimit.SpecifiedOut(
amountOut = replacedInQuoteAmount(newAmountInQuote, amountOut),
amountInQuote = newAmountInQuote,
amountInMax = replacedInQuoteAmount(newAmountInQuote, amountInMax)
)
}
fun SwapQuote.toExecuteArgs(slippage: Fraction, firstSegmentFees: FeePaymentCurrency): SwapFeeArgs {
return SwapFeeArgs(
assetIn = amountIn.chainAsset,
slippage = slippage,
direction = quotedPath.direction,
executionPath = quotedPath.path.map { quotedSwapEdge -> SegmentExecuteArgs(quotedSwapEdge) },
firstSegmentFees = firstSegmentFees
)
}
fun SwapLimit(direction: SwapDirection, amount: Balance, slippage: Fraction, quotedBalance: Balance): SwapLimit {
return when (direction) {
SwapDirection.SPECIFIED_IN -> SpecifiedIn(amount, slippage, quotedBalance)
SwapDirection.SPECIFIED_OUT -> SpecifiedOut(amount, slippage, quotedBalance)
}
}
private fun SpecifiedIn(amount: Balance, slippage: Fraction, quotedBalance: Balance): SwapLimit.SpecifiedIn {
val lessAmountCoefficient = BigDecimal.ONE - slippage.inFraction.toBigDecimal()
val amountOutMin = quotedBalance.toBigDecimal() * lessAmountCoefficient
return SwapLimit.SpecifiedIn(
amountIn = amount,
amountOutQuote = quotedBalance,
amountOutMin = amountOutMin.toBigInteger()
)
}
private fun SpecifiedOut(amount: Balance, slippage: Fraction, quotedBalance: Balance): SwapLimit.SpecifiedOut {
val moreAmountCoefficient = BigDecimal.ONE + slippage.inFraction.toBigDecimal()
val amountInMax = quotedBalance.toBigDecimal() * moreAmountCoefficient
return SwapLimit.SpecifiedOut(
amountOut = amount,
amountInQuote = quotedBalance,
amountInMax = amountInMax.toBigInteger()
)
}
@@ -0,0 +1,10 @@
package io.novafoundation.nova.feature_swap_api.domain.model
import io.novafoundation.nova.feature_wallet_api.data.network.blockhain.types.Balance
/**
* Deductions from account balance other than those caused by fees
*/
class SwapMaxAdditionalAmountDeduction(
val fromCountedTowardsEd: Balance
)
@@ -0,0 +1,6 @@
package io.novafoundation.nova.feature_swap_api.domain.model
sealed class SwapOperationSubmissionException : Throwable() {
class SimulationFailed : SwapOperationSubmissionException()
}
@@ -0,0 +1,16 @@
package io.novafoundation.nova.feature_swap_api.domain.model
sealed class SwapProgress {
class StepStarted(val step: SwapProgressStep) : SwapProgress()
class Failure(val error: Throwable, val attemptedStep: SwapProgressStep) : SwapProgress()
data object Done : SwapProgress()
}
class SwapProgressStep(
val index: Int,
val displayData: AtomicOperationDisplayData,
val operation: AtomicSwapOperation
)
@@ -0,0 +1,45 @@
package io.novafoundation.nova.feature_swap_api.domain.model
import io.novafoundation.nova.common.utils.Fraction
import io.novafoundation.nova.feature_swap_core_api.data.paths.model.QuotedPath
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.feature_wallet_api.domain.model.ChainAssetWithAmount
import io.novafoundation.nova.feature_wallet_api.domain.model.amountFromPlanks
import io.novafoundation.nova.runtime.multiNetwork.chain.model.Chain
import java.math.BigDecimal
data class SwapQuote(
val amountIn: ChainAssetWithAmount,
val amountOut: ChainAssetWithAmount,
val priceImpact: Fraction,
val quotedPath: QuotedPath<SwapGraphEdge>,
val executionEstimate: SwapExecutionEstimate,
val direction: SwapDirection,
) {
val assetIn: Chain.Asset
get() = amountIn.chainAsset
val assetOut: Chain.Asset
get() = amountOut.chainAsset
val planksIn: Balance
get() = amountIn.amount
val planksOut: Balance
get() = amountOut.amount
}
fun SwapQuote.swapRate(): BigDecimal {
return amountIn rateAgainst amountOut
}
infix fun ChainAssetWithAmount.rateAgainst(assetOut: ChainAssetWithAmount): BigDecimal {
if (amount == Balance.ZERO) return BigDecimal.ZERO
val amountIn = chainAsset.amountFromPlanks(amount)
val amountOut = assetOut.chainAsset.amountFromPlanks(assetOut.amount)
return amountOut / amountIn
}
@@ -0,0 +1,29 @@
package io.novafoundation.nova.feature_swap_api.domain.model
import io.novafoundation.nova.common.utils.Fraction
import io.novafoundation.nova.common.utils.graph.Path
import io.novafoundation.nova.feature_account_api.data.fee.FeePaymentCurrency
import io.novafoundation.nova.feature_swap_core_api.data.paths.model.QuotedEdge
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.feature_wallet_api.domain.model.Token
import io.novafoundation.nova.runtime.multiNetwork.chain.model.Chain
data class SwapQuoteArgs(
val tokenIn: Token,
val tokenOut: Token,
val amount: Balance,
val swapDirection: SwapDirection,
)
open class SwapFeeArgs(
val assetIn: Chain.Asset,
val slippage: Fraction,
val executionPath: Path<SegmentExecuteArgs>,
val direction: SwapDirection,
val firstSegmentFees: FeePaymentCurrency
)
class SegmentExecuteArgs(
val quotedSwapEdge: QuotedEdge<SwapGraphEdge>,
)
@@ -0,0 +1,30 @@
package io.novafoundation.nova.feature_swap_api.domain.model.fee
import io.novafoundation.nova.feature_swap_api.domain.model.AtomicOperationFeeDisplayData
import io.novafoundation.nova.feature_swap_api.domain.model.FeeWithLabel
import io.novafoundation.nova.feature_swap_api.domain.model.SubmissionFeeWithLabel
interface AtomicSwapOperationFee {
/**
* Fee that is paid when submitting transaction
*/
val submissionFee: SubmissionFeeWithLabel
val postSubmissionFees: PostSubmissionFees
fun constructDisplayData(): AtomicOperationFeeDisplayData
class PostSubmissionFees(
/**
* Post-submission fees paid by (some) origin account.
* This is typed as `SubmissionFee` as those fee might still use different accounts (e.g. delivery fees are always paid from requested account)
*/
val paidByAccount: List<SubmissionFeeWithLabel> = emptyList(),
/**
* Post-submission fees paid from swapping amount directly. Its payment is isolated and does not involve any withdrawals from accounts
*/
val paidFromAmount: List<FeeWithLabel> = emptyList()
)
}
@@ -0,0 +1,25 @@
package io.novafoundation.nova.feature_swap_api.domain.model.fee
import io.novafoundation.nova.feature_account_api.data.model.SubmissionFee
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.SubmissionFeeWithLabel
import io.novafoundation.nova.feature_swap_api.domain.model.fee.AtomicSwapOperationFee.PostSubmissionFees
class SubmissionOnlyAtomicSwapOperationFee(submissionFee: SubmissionFee) : AtomicSwapOperationFee {
override val submissionFee: SubmissionFeeWithLabel = SubmissionFeeWithLabel(submissionFee)
override val postSubmissionFees: PostSubmissionFees = PostSubmissionFees()
override fun constructDisplayData(): AtomicOperationFeeDisplayData {
return AtomicOperationFeeDisplayData(
components = listOf(
SwapFeeComponentDisplay(
type = AtomicOperationFeeDisplayData.SwapFeeType.NETWORK,
fees = listOf(submissionFee)
)
),
)
}
}
@@ -0,0 +1,46 @@
package io.novafoundation.nova.feature_swap_api.domain.swap
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.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.flow.Flow
interface SwapService {
suspend fun warmUpCommonChains(computationScope: CoroutineScope): Result<Unit>
suspend fun sync(coroutineScope: CoroutineScope)
suspend fun assetsAvailableForSwap(computationScope: CoroutineScope): Flow<Set<FullChainAssetId>>
suspend fun availableSwapDirectionsFor(asset: Chain.Asset, computationScope: CoroutineScope): Flow<Set<FullChainAssetId>>
suspend fun hasAvailableSwapDirections(asset: Chain.Asset, computationScope: CoroutineScope): Flow<Boolean>
suspend fun quote(
args: SwapQuoteArgs,
computationSharingScope: CoroutineScope
): Result<SwapQuote>
suspend fun estimateFee(executeArgs: SwapFeeArgs): SwapFee
suspend fun swap(calculatedFee: SwapFee): Flow<SwapProgress>
suspend fun submitFirstSwapStep(calculatedFee: SwapFee): Result<SwapSubmissionResult>
suspend fun defaultSlippageConfig(chainId: ChainId): SlippageConfig
fun runSubscriptions(metaAccount: MetaAccount): Flow<ReQuoteTrigger>
suspend fun isDeepSwapAllowed(): Boolean
}
@@ -0,0 +1,9 @@
package io.novafoundation.nova.feature_swap_api.presentation.formatters
import io.novafoundation.nova.runtime.multiNetwork.chain.model.Chain
import java.math.BigDecimal
interface SwapRateFormatter {
fun format(rate: BigDecimal, assetIn: Chain.Asset, assetOut: Chain.Asset): String
}
@@ -0,0 +1,25 @@
package io.novafoundation.nova.feature_swap_api.presentation.model
import android.os.Parcelable
import io.novafoundation.nova.feature_swap_core_api.data.primitive.model.SwapDirection
import kotlinx.android.parcel.Parcelize
@Parcelize
enum class SwapDirectionParcel : Parcelable {
SPECIFIED_IN,
SPECIFIED_OUT
}
fun SwapDirectionParcel.mapFromModel(): SwapDirection {
return when (this) {
SwapDirectionParcel.SPECIFIED_IN -> SwapDirection.SPECIFIED_IN
SwapDirectionParcel.SPECIFIED_OUT -> SwapDirection.SPECIFIED_OUT
}
}
fun SwapDirection.toParcel(): SwapDirectionParcel {
return when (this) {
SwapDirection.SPECIFIED_IN -> SwapDirectionParcel.SPECIFIED_IN
SwapDirection.SPECIFIED_OUT -> SwapDirectionParcel.SPECIFIED_OUT
}
}
@@ -0,0 +1,22 @@
package io.novafoundation.nova.feature_swap_api.presentation.model
import android.os.Parcelable
import io.novafoundation.nova.feature_wallet_api.data.network.blockhain.types.Balance
import kotlinx.parcelize.Parcelize
import io.novafoundation.nova.feature_wallet_api.presentation.model.AssetPayload
sealed interface SwapSettingsPayload : Parcelable {
val assetIn: AssetPayload
@Parcelize
class DefaultFlow(override val assetIn: AssetPayload) : SwapSettingsPayload
@Parcelize
class RepeatOperation(
override val assetIn: AssetPayload,
val assetOut: AssetPayload,
val amount: Balance,
val direction: SwapDirectionParcel
) : SwapSettingsPayload
}
@@ -0,0 +1,8 @@
package io.novafoundation.nova.feature_swap_api.presentation.navigation
import kotlinx.coroutines.CoroutineScope
interface SwapFlowScopeAggregator {
fun getFlowScope(screenScope: CoroutineScope): CoroutineScope
}
@@ -0,0 +1,17 @@
package io.novafoundation.nova.feature_swap_api.presentation.state
import io.novafoundation.nova.common.utils.Fraction
import io.novafoundation.nova.common.utils.Fraction.Companion.percents
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
val DEFAULT_SLIPPAGE = 0.5.percents
data class SwapSettings(
val assetIn: Chain.Asset? = null,
val assetOut: Chain.Asset? = null,
val amount: Balance? = null,
val swapDirection: SwapDirection? = null,
val slippage: Fraction
)
@@ -0,0 +1,22 @@
package io.novafoundation.nova.feature_swap_api.presentation.state
import io.novafoundation.nova.common.utils.Fraction
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 io.novafoundation.nova.runtime.state.SelectedOptionSharedState
interface SwapSettingsState : SelectedOptionSharedState<SwapSettings> {
suspend fun setAssetIn(asset: Chain.Asset)
fun setAssetOut(asset: Chain.Asset)
fun setAmount(amount: Balance?, swapDirection: SwapDirection)
fun setSlippage(slippage: Fraction)
suspend fun flipAssets(): SwapSettings
fun setSwapSettings(swapSettings: SwapSettings)
}
@@ -0,0 +1,8 @@
package io.novafoundation.nova.feature_swap_api.presentation.state
import kotlinx.coroutines.CoroutineScope
interface SwapSettingsStateProvider {
suspend fun getSwapSettingsState(coroutineScope: CoroutineScope): SwapSettingsState
}
@@ -0,0 +1,67 @@
package io.novafoundation.nova.feature_swap_api.presentation.view
import android.content.Context
import android.util.AttributeSet
import androidx.annotation.ColorRes
import androidx.constraintlayout.widget.ConstraintLayout
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.inflater
import io.novafoundation.nova.common.utils.setTextColorRes
import io.novafoundation.nova.common.utils.setTextOrHide
import io.novafoundation.nova.common.view.shape.getInputBackground
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_account_api.presenatation.chain.setTokenIcon
import io.novafoundation.nova.feature_swap_api.R
import io.novafoundation.nova.feature_swap_api.databinding.ViewSwapAssetBinding
import io.novafoundation.nova.feature_wallet_api.presentation.model.AmountModel
class SwapAssetView @JvmOverloads constructor(
context: Context,
attrs: AttributeSet? = null,
defStyleAttr: Int = 0
) : ConstraintLayout(context, attrs, defStyleAttr),
WithContextExtensions by WithContextExtensions(context) {
private val binder = ViewSwapAssetBinding.inflate(inflater(), this)
private val imageLoader: ImageLoader by lazy(LazyThreadSafetyMode.NONE) {
FeatureUtils.getCommonApi(context).imageLoader()
}
init {
background = context.getInputBackground()
}
fun setModel(model: Model) {
setAssetImageUrl(model.assetIcon)
setAmount(model.amount)
setNetwork(model.chainUi)
binder.swapAssetAmount.setTextColorRes(model.amountTextColorRes)
}
private fun setAssetImageUrl(icon: Icon) {
binder.swapAssetImage.setTokenIcon(icon, imageLoader)
binder.swapAssetImage.setBackgroundResource(R.drawable.bg_token_container)
}
private fun setAmount(amount: AmountModel) {
binder.swapAssetAmount.text = amount.token
binder.swapAssetFiat.setTextOrHide(amount.fiat)
}
private fun setNetwork(chainUi: ChainUi) {
binder.swapAssetNetworkImage.loadChainIcon(chainUi.icon, imageLoader)
binder.swapAssetNetwork.text = chainUi.name
}
class Model(
val assetIcon: Icon,
val amount: AmountModel,
val chainUi: ChainUi,
@ColorRes val amountTextColorRes: Int = R.color.text_primary
)
}
@@ -0,0 +1,34 @@
package io.novafoundation.nova.feature_swap_api.presentation.view
import android.content.Context
import android.util.AttributeSet
import androidx.constraintlayout.widget.ConstraintLayout
import io.novafoundation.nova.common.utils.inflater
import io.novafoundation.nova.feature_swap_api.databinding.ViewSwapAssetsBinding
class SwapAssetsView @JvmOverloads constructor(
context: Context,
attrs: AttributeSet? = null,
defStyleAttr: Int = 0
) : ConstraintLayout(context, attrs, defStyleAttr) {
private val binder = ViewSwapAssetsBinding.inflate(inflater(), this)
fun setModel(model: Model) {
setAssetIn(model.assetIn)
setAssetOut(model.assetOut)
}
fun setAssetIn(model: SwapAssetView.Model) {
binder.viewSwapAssetsIn.setModel(model)
}
fun setAssetOut(model: SwapAssetView.Model) {
binder.viewSwapAssetsOut.setModel(model)
}
class Model(
val assetIn: SwapAssetView.Model,
val assetOut: SwapAssetView.Model
)
}
@@ -0,0 +1,25 @@
package io.novafoundation.nova.feature_swap_api.presentation.view.bottomSheet.description
import io.novafoundation.nova.common.view.bottomSheet.description.DescriptionBottomSheetLauncher
import io.novafoundation.nova.feature_swap_api.R
fun DescriptionBottomSheetLauncher.launchSwapRateDescription() {
launchDescriptionBottomSheet(
titleRes = R.string.swap_rate_title,
descriptionRes = R.string.swap_rate_description
)
}
fun DescriptionBottomSheetLauncher.launchPriceDifferenceDescription() {
launchDescriptionBottomSheet(
titleRes = R.string.swap_price_difference_title,
descriptionRes = R.string.swap_price_difference_description
)
}
fun DescriptionBottomSheetLauncher.launchSlippageDescription() {
launchDescriptionBottomSheet(
titleRes = R.string.swap_slippage_title,
descriptionRes = R.string.swap_slippage_description
)
}
@@ -0,0 +1,92 @@
<?xml version="1.0" encoding="utf-8"?>
<merge xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:app="http://schemas.android.com/apk/res-auto"
xmlns:tools="http://schemas.android.com/tools"
android:layout_width="match_parent"
android:layout_height="wrap_content"
tools:background="@color/divider"
tools:parentTag="androidx.constraintlayout.widget.ConstraintLayout">
<ImageView
android:id="@+id/swapAssetImage"
android:layout_width="40dp"
android:layout_height="40dp"
android:layout_marginTop="16dp"
android:background="@drawable/bg_token_container"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toTopOf="parent"
tools:src="@drawable/ic_token_dot_colored" />
<TextView
android:id="@+id/swapAssetAmount"
style="@style/TextAppearance.NovaFoundation.SemiBold.Body"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_marginStart="16dp"
android:layout_marginTop="8dp"
android:layout_marginEnd="16dp"
android:ellipsize="middle"
android:includeFontPadding="false"
android:singleLine="true"
android:textColor="@color/text_primary"
app:layout_constrainedWidth="true"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toBottomOf="@+id/swapAssetImage"
app:layout_constraintVertical_chainStyle="packed"
tools:text="50.79 USDT" />
<TextView
android:id="@+id/swapAssetFiat"
style="@style/TextAppearance.NovaFoundation.Regular.Footnote"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_marginStart="16dp"
android:layout_marginTop="2dp"
android:layout_marginEnd="16dp"
android:ellipsize="middle"
android:singleLine="true"
android:textColor="@color/text_secondary"
app:layout_constrainedWidth="true"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toBottomOf="@+id/swapAssetAmount"
tools:text="135.87$" />
<LinearLayout
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_marginTop="16dp"
android:layout_marginBottom="20dp"
android:gravity="center_vertical"
android:orientation="horizontal"
android:paddingHorizontal="12dp"
app:layout_constraintBottom_toBottomOf="parent"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toBottomOf="@+id/swapAssetFiat"
app:layout_goneMarginTop="36dp">
<ImageView
android:id="@+id/swapAssetNetworkImage"
android:layout_width="12dp"
android:layout_height="12dp"
android:layout_marginEnd="8dp"
app:layout_constraintBottom_toBottomOf="@+id/swapAssetNetwork"
app:layout_constraintStart_toStartOf="parent"
tools:src="@drawable/ic_pezkuwi_logo" />
<TextView
android:id="@+id/swapAssetNetwork"
style="@style/TextAppearance.NovaFoundation.Regular.Footnote"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:ellipsize="end"
android:includeFontPadding="false"
android:lines="1"
android:textColor="@color/text_secondary"
tools:text="Polkadot Asset Hub" />
</LinearLayout>
</merge>
@@ -0,0 +1,39 @@
<?xml version="1.0" encoding="utf-8"?>
<merge xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:app="http://schemas.android.com/apk/res-auto"
xmlns:tools="http://schemas.android.com/tools"
android:layout_width="match_parent"
android:layout_height="wrap_content"
tools:parentTag="androidx.constraintlayout.widget.ConstraintLayout">
<io.novafoundation.nova.feature_swap_api.presentation.view.SwapAssetView
android:id="@+id/viewSwapAssetsIn"
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_marginEnd="4dp"
app:layout_constraintEnd_toStartOf="@id/viewSwapAssetsOut"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toTopOf="parent" />
<io.novafoundation.nova.feature_swap_api.presentation.view.SwapAssetView
android:id="@+id/viewSwapAssetsOut"
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_marginStart="4dp"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintStart_toEndOf="@id/viewSwapAssetsIn"
app:layout_constraintTop_toTopOf="parent" />
<ImageView
android:layout_width="32dp"
android:layout_height="32dp"
android:background="@drawable/bg_secondary_background_circle"
android:padding="8dp"
android:src="@drawable/ic_arrow_right"
app:layout_constraintBottom_toBottomOf="parent"
app:layout_constraintEnd_toStartOf="@+id/viewSwapAssetsOut"
app:layout_constraintStart_toEndOf="@+id/viewSwapAssetsIn"
app:layout_constraintTop_toTopOf="parent"
app:tint="@color/icon_secondary" />
</merge>