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
+92
View File
@@ -0,0 +1,92 @@
apply plugin: 'kotlin-parcelize'
apply from: '../tests.gradle'
apply from: '../scripts/secrets.gradle'
android {
defaultConfig {
buildConfigField "String", "EHTERSCAN_API_KEY_MOONBEAM", readStringSecret("EHTERSCAN_API_KEY_MOONBEAM")
buildConfigField "String", "EHTERSCAN_API_KEY_MOONRIVER", readStringSecret("EHTERSCAN_API_KEY_MOONRIVER")
buildConfigField "String", "EHTERSCAN_API_KEY_ETHEREUM", readStringSecret("EHTERSCAN_API_KEY_ETHEREUM")
buildConfigField "String", "LEGACY_CROSS_CHAIN_CONFIG_URL", "\"https://raw.githubusercontent.com/pezkuwichain/pezkuwi-wallet-utils/refs/heads/master/xcm/v8/transfers_dev.json\""
buildConfigField "String", "DYNAMIC_CROSS_CHAIN_CONFIG_URL", "\"https://raw.githubusercontent.com/pezkuwichain/pezkuwi-wallet-utils/refs/heads/master/xcm/v8/transfers_dynamic_dev.json\""
}
buildTypes {
release {
minifyEnabled false
proguardFiles getDefaultProguardFile('proguard-android.txt'), 'proguard-rules.pro'
buildConfigField "String", "LEGACY_CROSS_CHAIN_CONFIG_URL", "\"https://raw.githubusercontent.com/pezkuwichain/pezkuwi-wallet-utils/master/xcm/v8/transfers.json\""
buildConfigField "String", "DYNAMIC_CROSS_CHAIN_CONFIG_URL", "\"https://raw.githubusercontent.com/pezkuwichain/pezkuwi-wallet-utils/master/xcm/v8/transfers_dynamic.json\""
}
}
namespace 'io.novafoundation.nova.feature_wallet_impl'
buildFeatures {
viewBinding true
}
}
dependencies {
implementation project(':core-db')
implementation project(':common')
implementation project(':feature-wallet-api')
implementation project(':feature-account-api')
implementation project(':feature-currency-api')
implementation project(":feature-swap-core")
implementation project(':runtime')
implementation project(':feature-xcm:api')
implementation kotlinDep
implementation androidDep
implementation materialDep
implementation cardViewDep
implementation constraintDep
implementation permissionsDep
implementation coroutinesDep
implementation coroutinesAndroidDep
implementation viewModelKtxDep
implementation liveDataKtxDep
implementation lifeCycleKtxDep
implementation daggerDep
implementation 'androidx.legacy:legacy-support-v4:1.0.0'
ksp daggerCompiler
implementation roomDep
ksp roomCompiler
implementation lifecycleDep
ksp lifecycleCompiler
implementation bouncyCastleDep
testImplementation jUnitDep
testImplementation mockitoDep
implementation substrateSdkDep
implementation gsonDep
implementation retrofitDep
implementation wsDep
implementation zXingCoreDep
implementation zXingEmbeddedDep
implementation insetterDep
implementation shimmerDep
androidTestImplementation androidTestRunnerDep
androidTestImplementation androidTestRulesDep
androidTestImplementation androidJunitDep
}
@@ -0,0 +1,2 @@
<manifest>
</manifest>
@@ -0,0 +1,73 @@
package io.novafoundation.nova.feature_wallet_impl.data.mappers
import io.novafoundation.nova.common.domain.balance.EDCountingMode
import io.novafoundation.nova.common.domain.balance.TransferableMode
import io.novafoundation.nova.common.utils.orZero
import io.novafoundation.nova.core_db.model.AssetLocal
import io.novafoundation.nova.core_db.model.AssetLocal.EDCountingModeLocal
import io.novafoundation.nova.core_db.model.AssetLocal.TransferableModeLocal
import io.novafoundation.nova.core_db.model.AssetWithToken
import io.novafoundation.nova.core_db.model.CurrencyLocal
import io.novafoundation.nova.core_db.model.TokenLocal
import io.novafoundation.nova.core_db.model.TokenWithCurrency
import io.novafoundation.nova.feature_currency_api.presentation.mapper.mapCurrencyFromLocal
import io.novafoundation.nova.feature_wallet_api.domain.model.Asset
import io.novafoundation.nova.feature_wallet_api.domain.model.CoinRateChange
import io.novafoundation.nova.feature_wallet_api.domain.model.Token
import io.novafoundation.nova.runtime.multiNetwork.chain.model.Chain
fun mapTokenWithCurrencyToToken(
tokenWithCurrency: TokenWithCurrency,
chainAsset: Chain.Asset,
): Token {
return mapTokenLocalToToken(
tokenWithCurrency.token ?: TokenLocal.createEmpty(chainAsset.symbol.value, tokenWithCurrency.currency.id),
tokenWithCurrency.currency,
chainAsset
)
}
fun mapTokenLocalToToken(
tokenLocal: TokenLocal?,
currencyLocal: CurrencyLocal,
chainAsset: Chain.Asset,
): Token {
return Token(
currency = mapCurrencyFromLocal(currencyLocal),
coinRate = tokenLocal?.recentRateChange?.let { CoinRateChange(tokenLocal.recentRateChange.orZero(), tokenLocal.rate.orZero()) },
configuration = chainAsset
)
}
fun mapAssetLocalToAsset(
assetLocal: AssetWithToken,
chainAsset: Chain.Asset
): Asset {
return with(assetLocal) {
Asset(
token = mapTokenLocalToToken(token, assetLocal.currency, chainAsset),
frozenInPlanks = asset?.frozenInPlanks.orZero(),
freeInPlanks = asset?.freeInPlanks.orZero(),
reservedInPlanks = asset?.reservedInPlanks.orZero(),
bondedInPlanks = asset?.bondedInPlanks.orZero(),
unbondingInPlanks = asset?.unbondingInPlanks.orZero(),
redeemableInPlanks = asset?.redeemableInPlanks.orZero(),
transferableMode = mapTransferableModeFromLocal(asset?.transferableMode),
edCountingMode = mapEdCountingModeFromLocal(asset?.edCountingMode)
)
}
}
private fun mapTransferableModeFromLocal(modeLocal: TransferableModeLocal?): TransferableMode {
return when (modeLocal ?: AssetLocal.defaultTransferableMode()) {
TransferableModeLocal.REGULAR -> TransferableMode.REGULAR
TransferableModeLocal.HOLDS_AND_FREEZES -> TransferableMode.HOLDS_AND_FREEZES
}
}
private fun mapEdCountingModeFromLocal(modeLocal: EDCountingModeLocal?): EDCountingMode {
return when (modeLocal ?: AssetLocal.defaultEdCountingMode()) {
EDCountingModeLocal.TOTAL -> EDCountingMode.TOTAL
EDCountingModeLocal.FREE -> EDCountingMode.FREE
}
}
@@ -0,0 +1,51 @@
package io.novafoundation.nova.feature_wallet_impl.data.mappers.crosschain
import io.novafoundation.nova.common.utils.asGsonParsedNumber
import io.novafoundation.nova.feature_wallet_impl.data.network.crosschain.JunctionsRemote
import io.novafoundation.nova.feature_xcm_api.multiLocation.AbsoluteMultiLocation
import io.novafoundation.nova.feature_xcm_api.multiLocation.MultiLocation
import io.novafoundation.nova.feature_xcm_api.multiLocation.MultiLocation.Junction
import io.novafoundation.nova.feature_xcm_api.multiLocation.RelativeMultiLocation
import io.novafoundation.nova.feature_xcm_api.multiLocation.toInterior
private const val PARENTS = "parents"
fun mapJunctionsRemoteToMultiLocation(
junctionsRemote: JunctionsRemote
): RelativeMultiLocation {
return if (PARENTS in junctionsRemote) {
val parents = junctionsRemote.getValue(PARENTS).asGsonParsedNumber().toInt()
val withoutParents = junctionsRemote - PARENTS
RelativeMultiLocation(
parents = parents,
interior = mapJunctionsRemoteToInterior(withoutParents)
)
} else {
RelativeMultiLocation(
parents = 0,
interior = mapJunctionsRemoteToInterior(junctionsRemote)
)
}
}
fun JunctionsRemote.toAbsoluteLocation(): AbsoluteMultiLocation {
return AbsoluteMultiLocation(mapJunctionsRemoteToInterior(this))
}
private fun mapJunctionsRemoteToInterior(
junctionsRemote: JunctionsRemote
): MultiLocation.Interior {
return junctionsRemote.map { (type, value) -> mapJunctionFromRemote(type, value) }
.toInterior()
}
fun mapJunctionFromRemote(type: String, value: Any?): Junction {
return when (type) {
"parachainId" -> Junction.ParachainId(value.asGsonParsedNumber())
"generalKey" -> Junction.GeneralKey(value as String)
"palletInstance" -> Junction.PalletInstance(value.asGsonParsedNumber())
"generalIndex" -> Junction.GeneralIndex(value.asGsonParsedNumber())
else -> throw IllegalArgumentException("Unknown junction type: $type")
}
}
@@ -0,0 +1,83 @@
package io.novafoundation.nova.feature_wallet_impl.data.mappers.crosschain
import io.novafoundation.nova.common.utils.flattenKeys
import io.novafoundation.nova.common.utils.mapToSet
import io.novafoundation.nova.feature_wallet_api.domain.model.xcm.dynamic.DynamicCrossChainTransfersConfiguration
import io.novafoundation.nova.feature_wallet_api.domain.model.xcm.dynamic.DynamicCrossChainTransfersConfiguration.AssetTransfers
import io.novafoundation.nova.feature_wallet_api.domain.model.xcm.dynamic.DynamicCrossChainTransfersConfiguration.CustomTeleportEntry
import io.novafoundation.nova.feature_wallet_api.domain.model.xcm.dynamic.DynamicCrossChainTransfersConfiguration.TransferDestination
import io.novafoundation.nova.feature_wallet_api.domain.model.xcm.dynamic.reserve.TokenReserveConfig
import io.novafoundation.nova.feature_wallet_api.domain.model.xcm.dynamic.reserve.TokenReserveRegistry
import io.novafoundation.nova.feature_wallet_impl.data.network.crosschain.dynamic.CustomTeleportEntryRemote
import io.novafoundation.nova.feature_wallet_impl.data.network.crosschain.dynamic.DynamicCrossChainOriginChainRemote
import io.novafoundation.nova.feature_wallet_impl.data.network.crosschain.dynamic.DynamicCrossChainTransfersConfigRemote
import io.novafoundation.nova.feature_wallet_impl.data.network.crosschain.dynamic.DynamicReserveLocationRemote
import io.novafoundation.nova.runtime.multiNetwork.chain.model.ChainId
import io.novafoundation.nova.runtime.multiNetwork.chain.model.FullChainAssetId
import io.novafoundation.nova.runtime.repository.ParachainInfoRepository
fun DynamicCrossChainTransfersConfigRemote.toDomain(parachainInfoRepository: ParachainInfoRepository): DynamicCrossChainTransfersConfiguration {
return DynamicCrossChainTransfersConfiguration(
reserveRegistry = constructReserveRegistry(parachainInfoRepository, assetsLocation, reserveIdOverrides),
chains = constructChains(chains),
customTeleports = constructCustomTeleports(customTeleports)
)
}
private fun constructCustomTeleports(
customTeleports: List<CustomTeleportEntryRemote>?
): Set<CustomTeleportEntry> {
return customTeleports.orEmpty().mapToSet { entry ->
with(entry) {
CustomTeleportEntry(FullChainAssetId(originChain, originAsset), destChain)
}
}
}
private fun constructReserveRegistry(
parachainInfoRepository: ParachainInfoRepository,
assetsLocation: Map<String, DynamicReserveLocationRemote>?,
reserveIdOverrides: Map<String, Map<Int, String>>?,
): TokenReserveRegistry {
return TokenReserveRegistry(
parachainInfoRepository = parachainInfoRepository,
reservesById = assetsLocation.orEmpty().mapValues { (_, reserve) ->
reserve.toDomain()
},
assetToReserveIdOverrides = reserveIdOverrides.orEmpty().flattenKeys(::FullChainAssetId)
)
}
private fun constructChains(
chains: List<DynamicCrossChainOriginChainRemote>?
): Map<ChainId, List<AssetTransfers>> {
return chains.orEmpty().associateBy(
keySelector = DynamicCrossChainOriginChainRemote::chainId,
valueTransform = ::constructTransfersForChain
)
}
private fun constructTransfersForChain(configRemote: DynamicCrossChainOriginChainRemote): List<AssetTransfers> {
return configRemote.assets.map { assetConfig ->
AssetTransfers(
assetId = assetConfig.assetId,
destinations = assetConfig.xcmTransfers.map { transfer ->
TransferDestination(
fullChainAssetId = FullChainAssetId(
transfer.getDestinationChainId(),
transfer.getDestinationAssetId()
),
hasDeliveryFee = transfer.hasDeliveryFee ?: false,
supportsXcmExecute = transfer.supportsXcmExecute ?: false,
)
}
)
}
}
private fun DynamicReserveLocationRemote.toDomain(): TokenReserveConfig {
return TokenReserveConfig(
reserveChainId = chainId,
tokenReserveLocation = multiLocation.toAbsoluteLocation()
)
}
@@ -0,0 +1,180 @@
package io.novafoundation.nova.feature_wallet_impl.data.mappers.crosschain
import io.novafoundation.nova.common.utils.asGsonParsedNumber
import io.novafoundation.nova.feature_wallet_api.domain.model.xcm.dynamic.reserve.TokenReserveConfig
import io.novafoundation.nova.feature_wallet_api.domain.model.xcm.dynamic.reserve.TokenReserveRegistry
import io.novafoundation.nova.feature_wallet_api.domain.model.xcm.legacy.AssetLocationPath
import io.novafoundation.nova.feature_wallet_api.domain.model.xcm.legacy.DeliveryFeeConfiguration
import io.novafoundation.nova.feature_wallet_api.domain.model.xcm.legacy.LegacyCrossChainTransfersConfiguration
import io.novafoundation.nova.feature_wallet_api.domain.model.xcm.legacy.LegacyCrossChainTransfersConfiguration.AssetTransfers
import io.novafoundation.nova.feature_wallet_api.domain.model.xcm.legacy.LegacyCrossChainTransfersConfiguration.ReserveLocation
import io.novafoundation.nova.feature_wallet_api.domain.model.xcm.legacy.LegacyCrossChainTransfersConfiguration.XcmDestination
import io.novafoundation.nova.feature_wallet_api.domain.model.xcm.legacy.LegacyCrossChainTransfersConfiguration.XcmFee
import io.novafoundation.nova.feature_wallet_api.domain.model.xcm.legacy.LegacyCrossChainTransfersConfiguration.XcmTransfer
import io.novafoundation.nova.feature_wallet_api.domain.model.xcm.legacy.XCMInstructionType
import io.novafoundation.nova.feature_wallet_api.domain.model.xcm.legacy.LegacyXcmTransferMethod
import io.novafoundation.nova.feature_wallet_impl.data.network.crosschain.legacy.LegacyCrossChainOriginAssetRemote
import io.novafoundation.nova.feature_wallet_impl.data.network.crosschain.legacy.LegacyCrossChainTransfersConfigRemote
import io.novafoundation.nova.feature_wallet_impl.data.network.crosschain.legacy.LegacyDeliveryFeeConfigRemote
import io.novafoundation.nova.feature_wallet_impl.data.network.crosschain.legacy.LegacyNetworkDeliveryFeeRemote
import io.novafoundation.nova.feature_wallet_impl.data.network.crosschain.legacy.LegacyReserveLocationRemote
import io.novafoundation.nova.feature_wallet_impl.data.network.crosschain.legacy.LegacyXcmDestinationRemote
import io.novafoundation.nova.feature_wallet_impl.data.network.crosschain.legacy.LegacyXcmFeeRemote
import io.novafoundation.nova.feature_wallet_impl.data.network.crosschain.legacy.LegacyXcmTransferRemote
import io.novafoundation.nova.feature_xcm_api.multiLocation.AbsoluteMultiLocation
import io.novafoundation.nova.runtime.multiNetwork.chain.model.ChainId
import io.novafoundation.nova.runtime.multiNetwork.chain.model.FullChainAssetId
import io.novafoundation.nova.runtime.repository.ParachainInfoRepository
fun LegacyCrossChainTransfersConfigRemote.toDomain(
parachainInfoRepository: ParachainInfoRepository
): LegacyCrossChainTransfersConfiguration {
val assetsLocations = assetsLocation.orEmpty().mapValues { (_, reserveLocationRemote) ->
mapReserveLocationFromRemote(reserveLocationRemote)
}
val feeInstructions = instructions.orEmpty().mapValues { (_, instructionsRemote) ->
instructionsRemote.map(::mapXcmInstructionFromRemote)
}
val chains = chains.orEmpty().associateBy(
keySelector = { it.chainId },
valueTransform = { it.assets.map(::mapAssetTransfersFromRemote) }
)
val networkDeliveryFee = networkDeliveryFee.orEmpty().mapValues { (_, networkDeliveryFeeRemote) ->
mapNetworkDeliveryFeeFromRemote(networkDeliveryFeeRemote)
}
return LegacyCrossChainTransfersConfiguration(
assetLocations = assetsLocations,
feeInstructions = feeInstructions,
instructionBaseWeights = networkBaseWeight.orEmpty(),
deliveryFeeConfigurations = networkDeliveryFee,
chains = chains,
reserveRegistry = constructLegacyReserveRegistry(parachainInfoRepository, assetsLocations, chains)
)
}
private fun constructLegacyReserveRegistry(
parachainInfoRepository: ParachainInfoRepository,
assetLocations: Map<String, ReserveLocation>,
chains: Map<ChainId, List<AssetTransfers>>
): TokenReserveRegistry {
return TokenReserveRegistry(
parachainInfoRepository = parachainInfoRepository,
reservesById = assetLocations.mapValues { (_, reserve) ->
TokenReserveConfig(
reserveChainId = reserve.chainId,
// Legacy config uses relative location for reserve, however in fact they are absolute
// I decided to not to refactor it but rather simply perform conversion here in-place
tokenReserveLocation = AbsoluteMultiLocation(reserve.multiLocation.interior)
)
},
assetToReserveIdOverrides = buildMap {
chains.forEach { (chainId, chainAssets) ->
chainAssets.map { chainAssetConfig ->
val key = FullChainAssetId(chainId, chainAssetConfig.assetId)
// We could check that the `assetLocation` differs from the asset symbol to avoid placing redundant overrides...
// But we don't since it does not matter much anyway
put(key, chainAssetConfig.assetLocation)
}
}
}
)
}
private fun mapNetworkDeliveryFeeFromRemote(networkDeliveryFeeRemote: LegacyNetworkDeliveryFeeRemote): DeliveryFeeConfiguration {
return DeliveryFeeConfiguration(
toParent = mapDeliveryFeeConfigFromRemote(networkDeliveryFeeRemote.toParent),
toParachain = mapDeliveryFeeConfigFromRemote(networkDeliveryFeeRemote.toParachain)
)
}
private fun mapDeliveryFeeConfigFromRemote(config: LegacyDeliveryFeeConfigRemote?): DeliveryFeeConfiguration.Type? {
if (config == null) return null
return when (config.type) {
"exponential" -> DeliveryFeeConfiguration.Type.Exponential(
factorPallet = config.factorPallet,
sizeBase = config.sizeBase,
sizeFactor = config.sizeFactor,
alwaysHoldingPays = config.alwaysHoldingPays ?: false
)
else -> throw IllegalArgumentException("Unknown delivery fee config type: ${config.type}")
}
}
private fun mapReserveLocationFromRemote(reserveLocationRemote: LegacyReserveLocationRemote): ReserveLocation {
return ReserveLocation(
chainId = reserveLocationRemote.chainId,
reserveFee = reserveLocationRemote.reserveFee?.let(::mapXcmFeeFromRemote),
multiLocation = mapJunctionsRemoteToMultiLocation(reserveLocationRemote.multiLocation)
)
}
private fun mapAssetTransfersFromRemote(remote: LegacyCrossChainOriginAssetRemote): AssetTransfers {
val assetLocationPath = when (remote.assetLocationPath.type) {
"absolute" -> AssetLocationPath.Absolute
"relative" -> AssetLocationPath.Relative
"concrete" -> {
val junctionsRemote = remote.assetLocationPath.path!!
AssetLocationPath.Concrete(mapJunctionsRemoteToMultiLocation(junctionsRemote))
}
else -> throw IllegalArgumentException("Unknown asset type")
}
return AssetTransfers(
assetId = remote.assetId,
assetLocationPath = assetLocationPath,
assetLocation = remote.assetLocation,
xcmTransfers = remote.xcmTransfers.map(::mapXcmTransferFromRemote)
)
}
private fun mapXcmTransferFromRemote(remote: LegacyXcmTransferRemote): XcmTransfer {
return XcmTransfer(
destination = mapXcmDestinationFromRemote(remote.destination),
type = mapXcmTransferTypeFromRemote(remote.type)
)
}
private fun mapXcmTransferTypeFromRemote(remote: String): LegacyXcmTransferMethod {
return when (remote) {
"xtokens" -> LegacyXcmTransferMethod.X_TOKENS
"xcmpallet" -> LegacyXcmTransferMethod.XCM_PALLET_RESERVE
"xcmpallet-teleport" -> LegacyXcmTransferMethod.XCM_PALLET_TELEPORT
"xcmpallet-transferAssets" -> LegacyXcmTransferMethod.XCM_PALLET_TRANSFER_ASSETS
else -> LegacyXcmTransferMethod.UNKNOWN
}
}
private fun mapXcmDestinationFromRemote(remote: LegacyXcmDestinationRemote): XcmDestination {
return XcmDestination(
chainId = remote.chainId,
assetId = remote.assetId,
fee = mapXcmFeeFromRemote(remote.fee)
)
}
private fun mapXcmFeeFromRemote(
remote: LegacyXcmFeeRemote
): XcmFee<String> {
val mode = when (remote.mode.type) {
"proportional" -> XcmFee.Mode.Proportional(remote.mode.value.asGsonParsedNumber())
"standard" -> XcmFee.Mode.Standard
else -> XcmFee.Mode.Unknown
}
return XcmFee(
mode = mode,
instructions = remote.instructions
)
}
private fun mapXcmInstructionFromRemote(instruction: String): XCMInstructionType = runCatching {
enumValueOf<XCMInstructionType>(instruction)
}.getOrDefault(XCMInstructionType.UNKNOWN)
@@ -0,0 +1,32 @@
@file:Suppress("EXPERIMENTAL_API_USAGE")
package io.novafoundation.nova.feature_wallet_impl.data.network.blockchain
import io.novafoundation.nova.common.data.network.runtime.binding.AccountInfo
import io.novafoundation.nova.common.di.scope.FeatureScope
import io.novafoundation.nova.common.utils.metadata
import io.novafoundation.nova.feature_wallet_api.data.repository.AccountInfoRepository
import io.novafoundation.nova.runtime.di.REMOTE_STORAGE_SOURCE
import io.novafoundation.nova.runtime.multiNetwork.chain.model.ChainId
import io.novafoundation.nova.runtime.storage.source.StorageDataSource
import io.novafoundation.nova.runtime.storage.source.query.api.queryNonNull
import io.novafoundation.nova.runtime.storage.typed.account
import io.novafoundation.nova.runtime.storage.typed.system
import io.novasama.substrate_sdk_android.runtime.AccountId
import javax.inject.Inject
import javax.inject.Named
@FeatureScope
internal class RealAccountInfoRepository @Inject constructor(
@Named(REMOTE_STORAGE_SOURCE) private val remoteStorageSource: StorageDataSource,
) : AccountInfoRepository {
override suspend fun getAccountInfo(
chainId: ChainId,
accountId: AccountId,
): AccountInfo {
return remoteStorageSource.query(chainId, applyStorageDefault = true) {
metadata.system.account.queryNonNull(accountId)
}
}
}
@@ -0,0 +1,25 @@
package io.novafoundation.nova.feature_wallet_impl.data.network.blockchain.api
import io.novafoundation.nova.feature_wallet_api.data.network.blockhain.assets.balances.model.StatemineAssetDetails
import io.novafoundation.nova.feature_wallet_impl.data.network.blockchain.assets.balances.statemine.bindAssetDetails
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.metadata.RuntimeMetadata
import io.novasama.substrate_sdk_android.runtime.metadata.module
import io.novasama.substrate_sdk_android.runtime.metadata.module.Module
typealias UntypedAssetsAssetId = Any
@JvmInline
value class AssetsApi(override val module: Module) : QueryableModule
context(StorageQueryContext)
fun RuntimeMetadata.assets(palletName: String): AssetsApi {
return AssetsApi(module(palletName))
}
context(StorageQueryContext)
val AssetsApi.asset: QueryableStorageEntry1<UntypedAssetsAssetId, StatemineAssetDetails>
get() = storage1("Asset", binding = { decoded, _ -> bindAssetDetails(decoded) })
@@ -0,0 +1,79 @@
package io.novafoundation.nova.feature_wallet_impl.data.network.blockchain.assets
import dagger.Lazy
import io.novafoundation.nova.feature_wallet_api.data.network.blockhain.assets.AssetSource
import io.novafoundation.nova.feature_wallet_api.data.network.blockhain.assets.AssetSourceRegistry
import io.novafoundation.nova.feature_wallet_api.data.network.blockhain.assets.balances.AssetBalance
import io.novafoundation.nova.feature_wallet_api.data.network.blockhain.assets.events.AssetEventDetector
import io.novafoundation.nova.feature_wallet_api.data.network.blockhain.assets.history.AssetHistory
import io.novafoundation.nova.feature_wallet_api.data.network.blockhain.assets.tranfers.AssetTransfers
import io.novafoundation.nova.feature_wallet_impl.data.network.blockchain.assets.common.orml.OrmlAssetSourceFactory
import io.novafoundation.nova.feature_wallet_impl.data.network.blockchain.assets.events.UnsupportedEventDetector
import io.novafoundation.nova.feature_wallet_impl.data.network.blockchain.assets.events.evmErc20.EvmErc20EventDetectorFactory
import io.novafoundation.nova.feature_wallet_impl.data.network.blockchain.assets.events.orml.OrmlAssetEventDetectorFactory
import io.novafoundation.nova.feature_wallet_impl.data.network.blockchain.assets.events.statemine.StatemineAssetEventDetectorFactory
import io.novafoundation.nova.feature_wallet_impl.data.network.blockchain.assets.events.utility.NativeAssetEventDetector
import io.novafoundation.nova.runtime.multiNetwork.chain.model.Chain
class StaticAssetSource(
override val transfers: AssetTransfers,
override val balance: AssetBalance,
override val history: AssetHistory,
) : AssetSource
// Use lazy to resolve possible circular dependencies
class TypeBasedAssetSourceRegistry(
private val nativeSource: Lazy<AssetSource>,
private val statemineSource: Lazy<AssetSource>,
private val ormlSourceFactory: Lazy<OrmlAssetSourceFactory>,
private val evmErc20Source: Lazy<AssetSource>,
private val evmNativeSource: Lazy<AssetSource>,
private val equilibriumAssetSource: Lazy<AssetSource>,
private val unsupportedBalanceSource: AssetSource,
private val nativeAssetEventDetector: NativeAssetEventDetector,
private val ormlAssetEventDetectorFactory: OrmlAssetEventDetectorFactory,
private val statemineAssetEventDetectorFactory: StatemineAssetEventDetectorFactory,
private val erc20EventDetectorFactory: EvmErc20EventDetectorFactory
) : AssetSourceRegistry {
override fun sourceFor(chainAsset: Chain.Asset): AssetSource {
return when (val type = chainAsset.type) {
is Chain.Asset.Type.Native -> nativeSource.get()
is Chain.Asset.Type.Statemine -> statemineSource.get()
is Chain.Asset.Type.Orml -> ormlSourceFactory.get().getSourceBySubtype(type.subType)
is Chain.Asset.Type.EvmErc20 -> evmErc20Source.get()
is Chain.Asset.Type.EvmNative -> evmNativeSource.get()
is Chain.Asset.Type.Equilibrium -> equilibriumAssetSource.get()
Chain.Asset.Type.Unsupported -> unsupportedBalanceSource
}
}
override fun allSources(): List<AssetSource> {
return buildList {
add(nativeSource.get())
add(statemineSource.get())
addAll(ormlSourceFactory.get().allSources())
add(evmNativeSource.get())
add(evmErc20Source.get())
add(equilibriumAssetSource.get())
}
}
override suspend fun getEventDetector(chainAsset: Chain.Asset): AssetEventDetector {
return when (chainAsset.type) {
is Chain.Asset.Type.Equilibrium,
Chain.Asset.Type.EvmNative,
Chain.Asset.Type.Unsupported -> UnsupportedEventDetector()
is Chain.Asset.Type.Statemine -> statemineAssetEventDetectorFactory.create(chainAsset)
is Chain.Asset.Type.Orml -> ormlAssetEventDetectorFactory.create(chainAsset)
Chain.Asset.Type.Native -> nativeAssetEventDetector
is Chain.Asset.Type.EvmErc20 -> erc20EventDetectorFactory.create(chainAsset)
}
}
}
@@ -0,0 +1,92 @@
package io.novafoundation.nova.feature_wallet_impl.data.network.blockchain.assets.balances
import io.novafoundation.nova.common.data.network.runtime.binding.HelperBinding
import io.novafoundation.nova.common.data.network.runtime.binding.bindList
import io.novafoundation.nova.common.data.network.runtime.binding.bindNumber
import io.novafoundation.nova.common.data.network.runtime.binding.bindString
import io.novafoundation.nova.common.data.network.runtime.binding.cast
import io.novafoundation.nova.common.data.network.runtime.binding.castToDictEnum
import io.novafoundation.nova.common.data.network.runtime.binding.castToList
import io.novafoundation.nova.common.data.network.runtime.binding.castToStruct
import io.novafoundation.nova.common.utils.second
import io.novafoundation.nova.core_db.dao.LockDao
import io.novafoundation.nova.core_db.model.BalanceLockLocal
import io.novafoundation.nova.feature_wallet_api.data.network.blockhain.types.Balance
import io.novafoundation.nova.feature_wallet_api.domain.model.BalanceLockId
import io.novafoundation.nova.runtime.multiNetwork.chain.model.ChainAssetId
import io.novafoundation.nova.runtime.multiNetwork.chain.model.ChainId
class BlockchainLock(
val id: BalanceLockId,
val amount: Balance
)
@HelperBinding
fun bindEquilibriumBalanceLocks(dynamicInstance: Any?): List<BlockchainLock>? {
if (dynamicInstance == null) return null
return bindList(dynamicInstance) { items ->
val item = items.castToList()
BlockchainLock(
bindLockIdString(item.first().cast()),
bindNumber(item.second().cast())
)
}
}
@HelperBinding
fun bindBalanceLocks(dynamicInstance: Any?): List<BlockchainLock> {
if (dynamicInstance == null) return emptyList()
return bindList(dynamicInstance) {
BlockchainLock(
bindLockIdString(it.castToStruct()["id"]),
bindNumber(it.castToStruct()["amount"])
)
}
}
fun bindBalanceFreezes(dynamicInstance: Any?): List<BlockchainLock> {
if (dynamicInstance == null) return emptyList()
return bindList(dynamicInstance) { item ->
val asStruct = item.castToStruct()
BlockchainLock(
bindFreezeId(asStruct["id"]),
bindNumber(asStruct["amount"])
)
}
}
private fun bindFreezeId(dynamicInstance: Any?): BalanceLockId {
val asEnum = dynamicInstance.castToDictEnum()
val module = asEnum.name
val moduleReason = asEnum.value.castToDictEnum().name
return BalanceLockId.fromPath(module, moduleReason)
}
private fun bindLockIdString(dynamicInstance: Any?): BalanceLockId {
val asString = bindString(dynamicInstance)
return BalanceLockId.fromFullId(asString)
}
fun mapBlockchainLockToLocal(
metaId: Long,
chainId: ChainId,
assetId: ChainAssetId,
lock: BlockchainLock
): BalanceLockLocal {
return BalanceLockLocal(metaId, chainId, assetId, lock.id.value, lock.amount)
}
suspend fun LockDao.updateLocks(locks: List<BlockchainLock>, metaId: Long, chainId: ChainId, chainAssetId: ChainAssetId) {
val balanceLocksLocal = locks.map { mapBlockchainLockToLocal(metaId, chainId, chainAssetId, it) }
updateLocks(balanceLocksLocal, metaId, chainId, chainAssetId)
}
suspend fun LockDao.updateLock(lock: BlockchainLock, metaId: Long, chainId: ChainId, chainAssetId: ChainAssetId) {
val balanceLocksLocal = mapBlockchainLockToLocal(metaId, chainId, chainAssetId, lock)
updateLocks(listOf(balanceLocksLocal), metaId, chainId, chainAssetId)
}
@@ -0,0 +1,45 @@
package io.novafoundation.nova.feature_wallet_impl.data.network.blockchain.assets.balances
import io.novafoundation.nova.core.updater.SharedRequestsBuilder
import io.novafoundation.nova.feature_account_api.domain.model.MetaAccount
import io.novafoundation.nova.feature_wallet_api.data.network.blockhain.assets.balances.AssetBalance
import io.novafoundation.nova.feature_wallet_api.data.network.blockhain.assets.balances.BalanceSyncUpdate
import io.novafoundation.nova.feature_wallet_api.data.network.blockhain.assets.balances.model.TransferableBalanceUpdatePoint
import io.novafoundation.nova.runtime.multiNetwork.chain.model.Chain
import io.novasama.substrate_sdk_android.runtime.AccountId
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.emptyFlow
class UnsupportedAssetBalance : AssetBalance {
override suspend fun startSyncingBalanceLocks(
metaAccount: MetaAccount,
chain: Chain,
chainAsset: Chain.Asset,
accountId: AccountId,
subscriptionBuilder: SharedRequestsBuilder
) = unsupported()
override fun isSelfSufficient(chainAsset: Chain.Asset) = unsupported()
override suspend fun existentialDeposit(chainAsset: Chain.Asset) = unsupported()
override suspend fun queryAccountBalance(chain: Chain, chainAsset: Chain.Asset, accountId: AccountId) = unsupported()
override suspend fun subscribeAccountBalanceUpdatePoint(
chain: Chain,
chainAsset: Chain.Asset,
accountId: AccountId,
): Flow<TransferableBalanceUpdatePoint> = unsupported()
override suspend fun startSyncingBalance(
chain: Chain,
chainAsset: Chain.Asset,
metaAccount: MetaAccount,
accountId: AccountId,
subscriptionBuilder: SharedRequestsBuilder
): Flow<BalanceSyncUpdate> {
return emptyFlow()
}
private fun unsupported(): Nothing = throw UnsupportedOperationException("Unsupported balance source")
}
@@ -0,0 +1,284 @@
package io.novafoundation.nova.feature_wallet_impl.data.network.blockchain.assets.balances.equilibrium
import android.util.Log
import io.novafoundation.nova.common.data.network.runtime.binding.BlockHash
import io.novafoundation.nova.common.data.network.runtime.binding.HelperBinding
import io.novafoundation.nova.common.data.network.runtime.binding.UseCaseBinding
import io.novafoundation.nova.common.data.network.runtime.binding.bindNumber
import io.novafoundation.nova.common.data.network.runtime.binding.cast
import io.novafoundation.nova.common.data.network.runtime.binding.castToDictEnum
import io.novafoundation.nova.common.data.network.runtime.binding.castToList
import io.novafoundation.nova.common.data.network.runtime.binding.castToStruct
import io.novafoundation.nova.common.data.network.runtime.binding.getList
import io.novafoundation.nova.common.data.network.runtime.binding.returnType
import io.novafoundation.nova.common.utils.LOG_TAG
import io.novafoundation.nova.common.utils.combine
import io.novafoundation.nova.common.utils.constantOrNull
import io.novafoundation.nova.common.utils.decodeValue
import io.novafoundation.nova.common.utils.eqBalances
import io.novafoundation.nova.common.utils.getAs
import io.novafoundation.nova.common.utils.hasUpdated
import io.novafoundation.nova.common.utils.orZero
import io.novafoundation.nova.common.utils.second
import io.novafoundation.nova.common.utils.system
import io.novafoundation.nova.core.updater.SharedRequestsBuilder
import io.novafoundation.nova.core_db.dao.AssetDao
import io.novafoundation.nova.core_db.dao.LockDao
import io.novafoundation.nova.core_db.model.AssetLocal
import io.novafoundation.nova.core_db.model.AssetLocal.EDCountingModeLocal
import io.novafoundation.nova.core_db.model.AssetLocal.TransferableModeLocal
import io.novafoundation.nova.feature_account_api.domain.model.MetaAccount
import io.novafoundation.nova.feature_wallet_api.data.cache.AssetCache
import io.novafoundation.nova.feature_wallet_api.data.network.blockhain.assets.balances.AssetBalance
import io.novafoundation.nova.feature_wallet_api.data.network.blockhain.assets.balances.BalanceSyncUpdate
import io.novafoundation.nova.feature_wallet_api.data.network.blockhain.assets.balances.model.ChainAssetBalance
import io.novafoundation.nova.feature_wallet_api.data.network.blockhain.assets.balances.model.TransferableBalanceUpdatePoint
import io.novafoundation.nova.feature_wallet_impl.data.network.blockchain.assets.balances.bindEquilibriumBalanceLocks
import io.novafoundation.nova.feature_wallet_impl.data.network.blockchain.assets.balances.updateLocks
import io.novafoundation.nova.runtime.ext.isUtilityAsset
import io.novafoundation.nova.runtime.ext.requireEquilibrium
import io.novafoundation.nova.runtime.multiNetwork.ChainRegistry
import io.novafoundation.nova.runtime.multiNetwork.chain.model.Chain
import io.novafoundation.nova.runtime.multiNetwork.getRuntime
import io.novafoundation.nova.runtime.multiNetwork.withRuntime
import io.novafoundation.nova.runtime.network.binding.number
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.fromHexOrNull
import io.novasama.substrate_sdk_android.runtime.metadata.module.StorageEntry
import io.novasama.substrate_sdk_android.runtime.metadata.storage
import io.novasama.substrate_sdk_android.runtime.metadata.storageKey
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.catch
import kotlinx.coroutines.flow.combine
import kotlinx.coroutines.flow.emptyFlow
import kotlinx.coroutines.flow.flowOf
import kotlinx.coroutines.flow.map
import java.math.BigInteger
class EquilibriumAssetBalance(
private val chainRegistry: ChainRegistry,
private val assetCache: AssetCache,
private val lockDao: LockDao,
private val assetDao: AssetDao,
private val remoteStorageSource: StorageDataSource,
) : AssetBalance {
private class ReservedAssetBalanceWithBlock(val assetId: Int, val reservedBalance: BigInteger, val block: BlockHash)
private class FreeAssetBalancesWithBlock(val lock: BigInteger?, val assets: List<FreeAssetBalance>)
private class FreeAssetBalance(val assetId: Int, val balance: BigInteger)
override suspend fun startSyncingBalanceLocks(
metaAccount: MetaAccount,
chain: Chain,
chainAsset: Chain.Asset,
accountId: AccountId,
subscriptionBuilder: SharedRequestsBuilder
): Flow<*> {
if (!chainAsset.isUtilityAsset) return emptyFlow<Unit>()
val runtime = chainRegistry.getRuntime(chain.id)
val storage = runtime.metadata.eqBalances().storage("Locked")
val key = storage.storageKey(runtime, accountId)
return subscriptionBuilder.subscribe(key)
.map { change ->
val balanceLocks = bindEquilibriumBalanceLocks(storage.decodeValue(change.value, runtime)).orEmpty()
lockDao.updateLocks(balanceLocks, metaAccount.id, chain.id, chainAsset.id)
}
}
override fun isSelfSufficient(chainAsset: Chain.Asset): Boolean {
return true
}
override suspend fun existentialDeposit(chainAsset: Chain.Asset): BigInteger {
return if (chainAsset.isUtilityAsset) {
chainRegistry.withRuntime(chainAsset.chainId) {
runtime.metadata.eqBalances().constantOrNull("ExistentialDepositBasic")?.getAs(number())
.orZero()
}
} else {
BigInteger.ZERO
}
}
override suspend fun queryAccountBalance(chain: Chain, chainAsset: Chain.Asset, accountId: AccountId): ChainAssetBalance {
val assetBalances = remoteStorageSource.query(
chain.id,
keyBuilder = { it.getAccountStorage().storageKey(it, accountId) },
binding = { scale, runtimeSnapshot -> bindEquilibriumBalances(chain, scale, runtimeSnapshot) }
)
val onChainAssetId = chainAsset.requireEquilibrium().id
val reservedBalance = remoteStorageSource.query(
chain.id,
keyBuilder = { it.getReservedStorage().storageKey(it, accountId, onChainAssetId) },
binding = { scale, runtimeSnapshot -> bindReservedBalance(scale, runtimeSnapshot) }
)
val assetBalance = assetBalances.assets
.firstOrNull { it.assetId == chainAsset.id }
?.balance
.orZero()
val lockedBalance = assetBalances.lock.orZero().takeIf { chainAsset.isUtilityAsset } ?: BigInteger.ZERO
return ChainAssetBalance.default(
chainAsset = chainAsset,
free = assetBalance,
reserved = reservedBalance,
frozen = lockedBalance
)
}
override suspend fun subscribeAccountBalanceUpdatePoint(
chain: Chain,
chainAsset: Chain.Asset,
accountId: AccountId,
): Flow<TransferableBalanceUpdatePoint> {
TODO("Not yet implemented")
}
override suspend fun startSyncingBalance(
chain: Chain,
chainAsset: Chain.Asset,
metaAccount: MetaAccount,
accountId: AccountId,
subscriptionBuilder: SharedRequestsBuilder
): Flow<BalanceSyncUpdate> {
if (!chainAsset.isUtilityAsset) return emptyFlow()
val assetBalancesFlow = subscriptionBuilder.subscribeOnSystemAccount(chain, accountId)
val reservedBalanceFlow = subscriptionBuilder.subscribeOnReservedBalance(chain, accountId)
var oldBlockHash: String? = null
return combine(assetBalancesFlow, reservedBalanceFlow) { (blockHash, assetBalances), reservedBalancesWithBlocks ->
val freeByAssetId = assetBalances.assets.associateBy { it.assetId }
val reservedByAssetId = reservedBalancesWithBlocks.associateBy { it.assetId }
val diff = assetCache.updateAssetsByChain(metaAccount, chain) { asset: Chain.Asset ->
val free = freeByAssetId[asset.id]?.balance.orZero()
val reserved = reservedByAssetId[asset.id]?.reservedBalance.orZero()
val locks = if (asset.isUtilityAsset) assetBalances.lock.orZero() else BigInteger.ZERO
AssetLocal(
assetId = asset.id,
chainId = asset.chainId,
metaId = metaAccount.id,
freeInPlanks = free,
reservedInPlanks = reserved,
frozenInPlanks = locks,
transferableMode = TransferableModeLocal.REGULAR,
edCountingMode = EDCountingModeLocal.TOTAL,
redeemableInPlanks = BigInteger.ZERO,
bondedInPlanks = BigInteger.ZERO,
unbondingInPlanks = BigInteger.ZERO
)
}
if (diff.hasUpdated() && oldBlockHash != blockHash) {
oldBlockHash = blockHash
BalanceSyncUpdate.CauseFetchable(blockHash)
} else {
BalanceSyncUpdate.NoCause
}
}
}
private suspend fun SharedRequestsBuilder.subscribeOnSystemAccount(chain: Chain, accountId: AccountId): Flow<Pair<BlockHash, FreeAssetBalancesWithBlock>> {
val runtime = chainRegistry.getRuntime(chain.id)
val key = try {
runtime.getAccountStorage().storageKey(runtime, accountId)
} catch (e: Exception) {
Log.e(LOG_TAG, "Failed to construct account storage key: ${e.message} in ${chain.name}")
return emptyFlow()
}
return subscribe(key)
.map { it.block to bindEquilibriumBalances(chain, it.value, runtime) }
}
private suspend fun SharedRequestsBuilder.subscribeOnReservedBalance(chain: Chain, accountId: AccountId): Flow<List<ReservedAssetBalanceWithBlock>> {
val runtime = chainRegistry.getRuntime(chain.id)
return chain.assets
.filter { it.type is Chain.Asset.Type.Equilibrium }
.map { asset ->
val equilibriumType = asset.requireEquilibrium()
val key = try {
runtime.getReservedStorage().storageKey(runtime, accountId, equilibriumType.id)
} catch (e: Exception) {
Log.e(LOG_TAG, "Failed to construct key: ${e.message} in ${chain.name}")
return@map flowOf(null)
}
subscribe(key)
.map { ReservedAssetBalanceWithBlock(asset.id, bindReservedBalance(it.value, runtime), it.block) }
.catch<ReservedAssetBalanceWithBlock?> { emit(null) }
}.combine()
.map { it.filterNotNull() }
}
private fun bindReservedBalance(raw: String?, runtime: RuntimeSnapshot): BigInteger {
val type = runtime.getReservedStorage().returnType()
return raw?.let { type.fromHexOrNull(runtime, it).cast<BigInteger>() } ?: BigInteger.ZERO
}
@UseCaseBinding
private fun bindEquilibriumBalances(chain: Chain, scale: String?, runtime: RuntimeSnapshot): FreeAssetBalancesWithBlock {
if (scale == null) {
return FreeAssetBalancesWithBlock(null, emptyList())
}
val type = runtime.getAccountStorage().returnType()
val data = type.fromHexOrNull(runtime, scale)
.castToStruct()
.get<Any>("data").castToDictEnum()
.value
.castToStruct()
val lock = data.get<BigInteger>("lock")
val balances = data.getList("balance")
val onChainAssetIdToAsset = chain.assets
.associateBy { it.requireEquilibrium().id }
val assetBalances = balances.mapNotNull { assetBalance ->
val (onChainAssetId, balance) = bindAssetBalance(assetBalance.castToList())
val asset = onChainAssetIdToAsset[onChainAssetId]
asset?.let { FreeAssetBalance(it.id, balance) }
}
return FreeAssetBalancesWithBlock(lock, assetBalances)
}
@HelperBinding
private fun bindAssetBalance(dynamicInstance: List<Any?>): Pair<BigInteger, BigInteger> {
val onChainAssetId = bindNumber(dynamicInstance.first())
val balance = dynamicInstance.second().castToDictEnum()
val amount = if (balance.name == "Positive") {
bindNumber(balance.value)
} else {
BigInteger.ZERO
}
return onChainAssetId to amount
}
private fun RuntimeSnapshot.getAccountStorage(): StorageEntry {
return metadata.system().storage("Account")
}
private fun RuntimeSnapshot.getReservedStorage(): StorageEntry {
return metadata.eqBalances().storage("Reserved")
}
}
@@ -0,0 +1,258 @@
package io.novafoundation.nova.feature_wallet_impl.data.network.blockchain.assets.balances.evmErc20
import io.novafoundation.nova.common.utils.removeHexPrefix
import io.novafoundation.nova.core.ethereum.Web3Api
import io.novafoundation.nova.core.ethereum.log.Topic
import io.novafoundation.nova.core.updater.EthereumSharedRequestsBuilder
import io.novafoundation.nova.core.updater.SharedRequestsBuilder
import io.novafoundation.nova.core.updater.callApiOrThrow
import io.novafoundation.nova.feature_account_api.domain.model.MetaAccount
import io.novafoundation.nova.feature_wallet_api.data.cache.AssetCache
import io.novafoundation.nova.feature_wallet_api.data.cache.updateNonLockableAsset
import io.novafoundation.nova.feature_wallet_api.data.network.blockhain.assets.balances.AssetBalance
import io.novafoundation.nova.feature_wallet_api.data.network.blockhain.assets.balances.BalanceSyncUpdate
import io.novafoundation.nova.feature_wallet_api.data.network.blockhain.assets.balances.model.ChainAssetBalance
import io.novafoundation.nova.feature_wallet_api.data.network.blockhain.assets.balances.model.TransferableBalanceUpdatePoint
import io.novafoundation.nova.feature_wallet_api.data.network.blockhain.assets.history.realtime.RealtimeHistoryUpdate
import io.novafoundation.nova.feature_wallet_api.data.network.blockhain.types.Balance
import io.novafoundation.nova.feature_wallet_api.domain.model.Operation
import io.novafoundation.nova.runtime.ethereum.contract.base.queryBatched
import io.novafoundation.nova.runtime.ethereum.contract.base.querySingle
import io.novafoundation.nova.runtime.ethereum.contract.erc20.Erc20Queries
import io.novafoundation.nova.runtime.ethereum.contract.erc20.Erc20Standard
import io.novafoundation.nova.runtime.ext.addressOf
import io.novafoundation.nova.runtime.ext.requireErc20
import io.novafoundation.nova.runtime.multiNetwork.ChainRegistry
import io.novafoundation.nova.runtime.multiNetwork.chain.model.Chain
import io.novafoundation.nova.runtime.multiNetwork.getCallEthereumApiOrThrow
import io.novafoundation.nova.runtime.multiNetwork.getSubscriptionEthereumApiOrThrow
import io.novafoundation.nova.runtime.network.rpc.RpcCalls
import io.novasama.substrate_sdk_android.extensions.asEthereumAddress
import io.novasama.substrate_sdk_android.extensions.toAccountId
import io.novasama.substrate_sdk_android.runtime.AccountId
import kotlinx.coroutines.Deferred
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.emitAll
import kotlinx.coroutines.flow.emptyFlow
import kotlinx.coroutines.flow.flow
import kotlinx.coroutines.flow.map
import kotlinx.coroutines.flow.mapLatest
import kotlinx.coroutines.flow.merge
import org.web3j.abi.EventEncoder
import org.web3j.abi.TypeEncoder
import org.web3j.abi.datatypes.Address
import org.web3j.protocol.websocket.events.Log
import org.web3j.protocol.websocket.events.LogNotification
import java.math.BigInteger
private const val BATCH_ID = "EvmAssetBalance.InitialBalance"
class EvmErc20AssetBalance(
private val chainRegistry: ChainRegistry,
private val assetCache: AssetCache,
private val erc20Standard: Erc20Standard,
private val rpcCalls: RpcCalls,
) : AssetBalance {
override suspend fun startSyncingBalanceLocks(
metaAccount: MetaAccount,
chain: Chain,
chainAsset: Chain.Asset,
accountId: AccountId,
subscriptionBuilder: SharedRequestsBuilder
): Flow<*> {
// ERC20 tokens doe not support locks
return emptyFlow<Nothing>()
}
override fun isSelfSufficient(chainAsset: Chain.Asset): Boolean {
return true
}
override suspend fun existentialDeposit(chainAsset: Chain.Asset): BigInteger {
// ERC20 tokens do not have ED
return BigInteger.ZERO
}
override suspend fun queryAccountBalance(chain: Chain, chainAsset: Chain.Asset, accountId: AccountId): ChainAssetBalance {
val erc20Type = chainAsset.requireErc20()
val ethereumApi = chainRegistry.getCallEthereumApiOrThrow(chain.id)
val accountAddress = chain.addressOf(accountId)
val balance = erc20Standard.querySingle(erc20Type.contractAddress, ethereumApi)
.balanceOfAsync(accountAddress)
.await()
return ChainAssetBalance.fromFree(chainAsset, free = balance)
}
override suspend fun subscribeAccountBalanceUpdatePoint(
chain: Chain,
chainAsset: Chain.Asset,
accountId: AccountId,
): Flow<TransferableBalanceUpdatePoint> {
val ethereumApi = chainRegistry.getSubscriptionEthereumApiOrThrow(chain.id)
val address = chain.addressOf(accountId)
val erc20Type = chainAsset.requireErc20()
return merge(
ethereumApi.incomingErcTransfersFlow(address, erc20Type.contractAddress),
ethereumApi.outComingErcTransfersFlow(address, erc20Type.contractAddress)
).mapLatest { logNotification ->
val blockNumber = logNotification.params.result.parsedBlockNumber()
val substrateHash = rpcCalls.getBlockHash(chain.id, blockNumber)
TransferableBalanceUpdatePoint(updatedAt = substrateHash)
}
}
override suspend fun startSyncingBalance(
chain: Chain,
chainAsset: Chain.Asset,
metaAccount: MetaAccount,
accountId: AccountId,
subscriptionBuilder: SharedRequestsBuilder
): Flow<BalanceSyncUpdate> {
val address = chain.addressOf(accountId)
val erc20Type = chainAsset.requireErc20()
val initialBalanceAsync = erc20Standard.queryBatched(erc20Type.contractAddress, BATCH_ID, subscriptionBuilder)
.balanceOfAsync(address)
return subscriptionBuilder.erc20BalanceFlow(address, chainAsset, initialBalanceAsync)
.map { balanceUpdate ->
assetCache.updateNonLockableAsset(metaAccount.id, chainAsset, balanceUpdate.newBalance)
if (balanceUpdate.cause != null) {
BalanceSyncUpdate.CauseFetched(balanceUpdate.cause)
} else {
BalanceSyncUpdate.NoCause
}
}
}
private fun EthereumSharedRequestsBuilder.erc20BalanceFlow(
account: String,
chainAsset: Chain.Asset,
initialBalanceAsync: Deferred<BigInteger>
): Flow<Erc20BalanceUpdate> {
val contractAddress = chainAsset.requireErc20().contractAddress
val changes = accountErcTransfersFlow(account, contractAddress, chainAsset).map { erc20Transfer ->
val newBalance = erc20Standard.querySingle(contractAddress, callApiOrThrow)
.balanceOfAsync(account)
.await()
Erc20BalanceUpdate(newBalance, cause = erc20Transfer)
}
return flow {
val initialBalance = initialBalanceAsync.await()
emit(Erc20BalanceUpdate(initialBalance, cause = null))
emitAll(changes)
}
}
private fun Web3Api.incomingErcTransfersFlow(
accountAddress: String,
contractAddress: String,
): Flow<LogNotification> {
return logsNotifications(
addresses = listOf(contractAddress),
topics = createErc20ReceiveTopics(accountAddress)
)
}
private fun Web3Api.outComingErcTransfersFlow(
accountAddress: String,
contractAddress: String,
): Flow<LogNotification> {
return logsNotifications(
addresses = listOf(contractAddress),
topics = createErc20SendTopics(accountAddress)
)
}
private fun createErc20ReceiveTopics(accountAddress: String): List<Topic> {
val addressTopic = TypeEncoder.encode(Address(accountAddress))
val transferEventSignature = EventEncoder.encode(Erc20Queries.TRANSFER_EVENT)
return createErc20ReceiveTopics(transferEventSignature, addressTopic)
}
private fun createErc20ReceiveTopics(
transferEventSignature: String,
addressTopic: String,
): List<Topic> {
return listOf(
Topic.Single(transferEventSignature), // zero-th topic is event signature
Topic.Any, // anyone is `from`
Topic.AnyOf(addressTopic) // our account as `to`
)
}
private fun createErc20SendTopics(accountAddress: String): List<Topic> {
val addressTopic = TypeEncoder.encode(Address(accountAddress))
val transferEventSignature = EventEncoder.encode(Erc20Queries.TRANSFER_EVENT)
return createErc20SendTopics(transferEventSignature, addressTopic)
}
private fun createErc20SendTopics(
transferEventSignature: String,
addressTopic: String,
): List<Topic> {
return listOf(
Topic.Single(transferEventSignature), // zero-th topic is event signature
Topic.AnyOf(addressTopic), // our account as `from`
)
}
private fun EthereumSharedRequestsBuilder.accountErcTransfersFlow(
accountAddress: String,
contractAddress: String,
chainAsset: Chain.Asset,
): Flow<RealtimeHistoryUpdate> {
val addressTopic = TypeEncoder.encode(Address(accountAddress))
val transferEvent = Erc20Queries.TRANSFER_EVENT
val transferEventSignature = EventEncoder.encode(transferEvent)
val erc20SendTopic = createErc20SendTopics(transferEventSignature, addressTopic)
val erc20ReceiveTopic = createErc20ReceiveTopics(transferEventSignature, addressTopic)
val receiveTransferNotifications = subscribeEthLogs(contractAddress, erc20ReceiveTopic)
val sendTransferNotifications = subscribeEthLogs(contractAddress, erc20SendTopic)
val transferNotifications = merge(receiveTransferNotifications, sendTransferNotifications)
return transferNotifications.map { logNotification ->
val log = logNotification.params.result
val event = Erc20Queries.parseTransferEvent(log)
RealtimeHistoryUpdate(
status = Operation.Status.COMPLETED,
txHash = log.transactionHash,
type = RealtimeHistoryUpdate.Type.Transfer(
senderId = event.from.accountId(),
recipientId = event.to.accountId(),
amountInPlanks = event.amount.value,
chainAsset = chainAsset,
)
)
}
}
private fun Log.parsedBlockNumber(): BigInteger {
return BigInteger(blockNumber.removeHexPrefix(), 16)
}
}
private fun Address.accountId() = value.asEthereumAddress().toAccountId().value
private class Erc20BalanceUpdate(
val newBalance: Balance,
val cause: RealtimeHistoryUpdate?
)
@@ -0,0 +1,122 @@
package io.novafoundation.nova.feature_wallet_impl.data.network.blockchain.assets.balances.evmNative
import io.novafoundation.nova.core.ethereum.Web3Api
import io.novafoundation.nova.core.updater.SharedRequestsBuilder
import io.novafoundation.nova.feature_account_api.domain.model.MetaAccount
import io.novafoundation.nova.feature_wallet_api.data.cache.AssetCache
import io.novafoundation.nova.feature_wallet_api.data.cache.updateNonLockableAsset
import io.novafoundation.nova.feature_wallet_api.data.network.blockhain.assets.balances.AssetBalance
import io.novafoundation.nova.feature_wallet_api.data.network.blockhain.assets.balances.BalanceSyncUpdate
import io.novafoundation.nova.feature_wallet_api.data.network.blockhain.assets.balances.model.ChainAssetBalance
import io.novafoundation.nova.feature_wallet_api.data.network.blockhain.assets.balances.model.TransferableBalanceUpdatePoint
import io.novafoundation.nova.feature_wallet_api.data.network.blockhain.types.Balance
import io.novafoundation.nova.runtime.ethereum.sendSuspend
import io.novafoundation.nova.runtime.ext.addressOf
import io.novafoundation.nova.runtime.multiNetwork.ChainRegistry
import io.novafoundation.nova.runtime.multiNetwork.chain.model.Chain
import io.novafoundation.nova.runtime.multiNetwork.getCallEthereumApiOrThrow
import io.novafoundation.nova.runtime.multiNetwork.getSubscriptionEthereumApiOrThrow
import io.novasama.substrate_sdk_android.runtime.AccountId
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.emitAll
import kotlinx.coroutines.flow.emptyFlow
import kotlinx.coroutines.flow.flow
import kotlinx.coroutines.flow.map
import kotlinx.coroutines.flow.transform
import org.web3j.protocol.core.DefaultBlockParameterName
import java.math.BigInteger
class EvmNativeAssetBalance(
private val assetCache: AssetCache,
private val chainRegistry: ChainRegistry,
) : AssetBalance {
override suspend fun startSyncingBalanceLocks(
metaAccount: MetaAccount,
chain: Chain,
chainAsset: Chain.Asset,
accountId: AccountId,
subscriptionBuilder: SharedRequestsBuilder
): Flow<*> {
// Evm native tokens doe not support locks
return emptyFlow<Nothing>()
}
override fun isSelfSufficient(chainAsset: Chain.Asset): Boolean {
return true
}
override suspend fun existentialDeposit(chainAsset: Chain.Asset): BigInteger {
// Evm native tokens do not have ED
return BigInteger.ZERO
}
override suspend fun queryAccountBalance(chain: Chain, chainAsset: Chain.Asset, accountId: AccountId): ChainAssetBalance {
val ethereumApi = chainRegistry.getCallEthereumApiOrThrow(chain.id)
val balance = ethereumApi.getLatestNativeBalance(chain.addressOf(accountId))
return ChainAssetBalance.fromFree(chainAsset, balance)
}
override suspend fun subscribeAccountBalanceUpdatePoint(
chain: Chain,
chainAsset: Chain.Asset,
accountId: AccountId,
): Flow<TransferableBalanceUpdatePoint> {
TODO("Not yet implemented")
}
override suspend fun startSyncingBalance(
chain: Chain,
chainAsset: Chain.Asset,
metaAccount: MetaAccount,
accountId: AccountId,
subscriptionBuilder: SharedRequestsBuilder
): Flow<BalanceSyncUpdate> {
val subscriptionApi = chainRegistry.getSubscriptionEthereumApiOrThrow(chain.id)
val callApi = chainRegistry.getCallEthereumApiOrThrow(chain.id)
val address = chain.addressOf(accountId)
return balanceSyncUpdateFlow(address, subscriptionApi, callApi).map { balanceUpdate ->
assetCache.updateNonLockableAsset(metaAccount.id, chainAsset, balanceUpdate.newBalance)
balanceUpdate.syncUpdate
}
}
private fun balanceSyncUpdateFlow(
address: String,
subscriptionApi: Web3Api,
callApi: Web3Api
): Flow<EvmNativeBalanceUpdate> {
return flow {
val initialBalance = callApi.getLatestNativeBalance(address)
emit(EvmNativeBalanceUpdate(initialBalance, BalanceSyncUpdate.NoCause))
var currentBalance = initialBalance
val realtimeUpdates = subscriptionApi.newHeadsFlow().transform { newHead ->
val blockHash = newHead.params.result.hash
val newBalance = callApi.getLatestNativeBalance(address)
if (newBalance != currentBalance) {
currentBalance = newBalance
val update = EvmNativeBalanceUpdate(newBalance, BalanceSyncUpdate.CauseFetchable(blockHash))
emit(update)
}
}
emitAll(realtimeUpdates)
}
}
private suspend fun Web3Api.getLatestNativeBalance(address: String): Balance {
return ethGetBalance(address, DefaultBlockParameterName.LATEST).sendSuspend().balance
}
}
private class EvmNativeBalanceUpdate(
val newBalance: Balance,
val syncUpdate: BalanceSyncUpdate,
)
@@ -0,0 +1,141 @@
package io.novafoundation.nova.feature_wallet_impl.data.network.blockchain.assets.balances.orml
import io.novafoundation.nova.common.data.network.runtime.binding.AccountBalance
import io.novafoundation.nova.common.data.network.runtime.binding.bindOrmlAccountBalanceOrEmpty
import io.novafoundation.nova.common.utils.decodeValue
import io.novafoundation.nova.common.utils.metadata
import io.novafoundation.nova.common.utils.tokens
import io.novafoundation.nova.core.updater.SharedRequestsBuilder
import io.novafoundation.nova.core_db.dao.LockDao
import io.novafoundation.nova.core_db.model.AssetLocal.EDCountingModeLocal
import io.novafoundation.nova.core_db.model.AssetLocal.TransferableModeLocal
import io.novafoundation.nova.feature_account_api.domain.model.MetaAccount
import io.novafoundation.nova.feature_wallet_api.data.cache.AssetCache
import io.novafoundation.nova.feature_wallet_api.data.network.blockhain.assets.balances.AssetBalance
import io.novafoundation.nova.feature_wallet_api.data.network.blockhain.assets.balances.BalanceSyncUpdate
import io.novafoundation.nova.feature_wallet_api.data.network.blockhain.assets.balances.model.ChainAssetBalance
import io.novafoundation.nova.feature_wallet_api.data.network.blockhain.assets.balances.model.TransferableBalanceUpdatePoint
import io.novafoundation.nova.feature_wallet_impl.data.network.blockchain.assets.balances.bindBalanceLocks
import io.novafoundation.nova.feature_wallet_impl.data.network.blockchain.assets.balances.updateLocks
import io.novafoundation.nova.runtime.ext.ormlCurrencyId
import io.novafoundation.nova.runtime.ext.requireOrml
import io.novafoundation.nova.runtime.multiNetwork.ChainRegistry
import io.novafoundation.nova.runtime.multiNetwork.chain.model.Chain
import io.novafoundation.nova.runtime.multiNetwork.getRuntime
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.metadata.storage
import io.novasama.substrate_sdk_android.runtime.metadata.storageKey
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.map
import java.math.BigInteger
class OrmlAssetBalance(
private val assetCache: AssetCache,
private val remoteStorageSource: StorageDataSource,
private val chainRegistry: ChainRegistry,
private val lockDao: LockDao
) : AssetBalance {
override suspend fun startSyncingBalanceLocks(
metaAccount: MetaAccount,
chain: Chain,
chainAsset: Chain.Asset,
accountId: AccountId,
subscriptionBuilder: SharedRequestsBuilder
): Flow<*> {
val runtime = chainRegistry.getRuntime(chain.id)
val storage = runtime.metadata.tokens().storage("Locks")
val currencyId = chainAsset.ormlCurrencyId(runtime)
val key = storage.storageKey(runtime, accountId, currencyId)
return subscriptionBuilder.subscribe(key)
.map { change ->
val balanceLocks = bindBalanceLocks(storage.decodeValue(change.value, runtime))
lockDao.updateLocks(balanceLocks, metaAccount.id, chain.id, chainAsset.id)
}
}
override fun isSelfSufficient(chainAsset: Chain.Asset): Boolean {
return true
}
override suspend fun existentialDeposit(chainAsset: Chain.Asset): BigInteger {
return chainAsset.requireOrml().existentialDeposit
}
override suspend fun queryAccountBalance(chain: Chain, chainAsset: Chain.Asset, accountId: AccountId): ChainAssetBalance {
val balance = remoteStorageSource.query(
chainId = chain.id,
keyBuilder = { it.ormlBalanceKey(accountId, chainAsset) },
binding = { scale, runtime -> bindOrmlAccountBalanceOrEmpty(scale, runtime) }
)
return ChainAssetBalance.default(chainAsset, balance)
}
override suspend fun subscribeAccountBalanceUpdatePoint(
chain: Chain,
chainAsset: Chain.Asset,
accountId: AccountId,
): Flow<TransferableBalanceUpdatePoint> {
return remoteStorageSource.subscribe(chain.id) {
metadata.tokens().storage("Accounts").observeWithRaw(
accountId,
chainAsset.ormlCurrencyId(runtime),
binding = ::bindOrmlAccountBalanceOrEmpty
).map {
TransferableBalanceUpdatePoint(it.at!!)
}
}
}
override suspend fun startSyncingBalance(
chain: Chain,
chainAsset: Chain.Asset,
metaAccount: MetaAccount,
accountId: AccountId,
subscriptionBuilder: SharedRequestsBuilder
): Flow<BalanceSyncUpdate> {
val runtime = chainRegistry.getRuntime(chain.id)
return subscriptionBuilder.subscribe(runtime.ormlBalanceKey(accountId, chainAsset))
.map {
val ormlAccountData = bindOrmlAccountBalanceOrEmpty(it.value, runtime)
val assetChanged = updateAssetBalance(metaAccount.id, chainAsset, ormlAccountData)
if (assetChanged) {
BalanceSyncUpdate.CauseFetchable(it.block)
} else {
BalanceSyncUpdate.NoCause
}
}
}
private suspend fun updateAssetBalance(
metaId: Long,
chainAsset: Chain.Asset,
ormlAccountData: AccountBalance
) = assetCache.updateAsset(metaId, chainAsset) { local ->
with(ormlAccountData) {
local.copy(
frozenInPlanks = frozen,
freeInPlanks = free,
reservedInPlanks = reserved,
transferableMode = TransferableModeLocal.REGULAR,
edCountingMode = EDCountingModeLocal.TOTAL,
)
}
}
private fun RuntimeSnapshot.ormlBalanceKey(accountId: AccountId, chainAsset: Chain.Asset): String {
return metadata.tokens().storage("Accounts").storageKey(this, accountId, chainAsset.ormlCurrencyId(this))
}
private fun bindOrmlAccountBalanceOrEmpty(scale: String?, runtime: RuntimeSnapshot): AccountBalance {
return scale?.let { bindOrmlAccountData(it, runtime) } ?: AccountBalance.empty()
}
}
@@ -0,0 +1,19 @@
package io.novafoundation.nova.feature_wallet_impl.data.network.blockchain.assets.balances.orml
import io.novafoundation.nova.common.data.network.runtime.binding.AccountBalance
import io.novafoundation.nova.common.data.network.runtime.binding.UseCaseBinding
import io.novafoundation.nova.common.data.network.runtime.binding.bindOrmlAccountData
import io.novafoundation.nova.common.data.network.runtime.binding.returnType
import io.novafoundation.nova.common.utils.tokens
import io.novasama.substrate_sdk_android.runtime.RuntimeSnapshot
import io.novasama.substrate_sdk_android.runtime.definitions.types.fromHexOrNull
import io.novasama.substrate_sdk_android.runtime.metadata.storage
@UseCaseBinding
fun bindOrmlAccountData(scale: String, runtime: RuntimeSnapshot): AccountBalance {
val type = runtime.metadata.tokens().storage("Accounts").returnType()
val dynamicInstance = type.fromHexOrNull(runtime, scale)
return bindOrmlAccountData(dynamicInstance)
}
@@ -0,0 +1,158 @@
package io.novafoundation.nova.feature_wallet_impl.data.network.blockchain.assets.balances.orml.hydrationEvm
import io.novafoundation.nova.common.data.network.runtime.binding.bindNumber
import io.novafoundation.nova.common.data.network.runtime.binding.castToStruct
import io.novafoundation.nova.common.di.scope.FeatureScope
import io.novafoundation.nova.common.domain.balance.EDCountingMode
import io.novafoundation.nova.common.domain.balance.TransferableMode
import io.novafoundation.nova.core.updater.SharedRequestsBuilder
import io.novafoundation.nova.feature_account_api.domain.model.MetaAccount
import io.novafoundation.nova.feature_wallet_api.data.cache.AssetCache
import io.novafoundation.nova.feature_wallet_api.data.cache.updateFromChainBalance
import io.novafoundation.nova.feature_wallet_api.data.network.blockhain.assets.balances.AssetBalance
import io.novafoundation.nova.feature_wallet_api.data.network.blockhain.assets.balances.BalanceSyncUpdate
import io.novafoundation.nova.feature_wallet_api.data.network.blockhain.assets.balances.model.ChainAssetBalance
import io.novafoundation.nova.feature_wallet_api.data.network.blockhain.assets.balances.model.TransferableBalanceUpdatePoint
import io.novafoundation.nova.feature_wallet_impl.data.network.blockchain.assets.balances.orml.OrmlAssetBalance
import io.novafoundation.nova.runtime.call.MultiChainRuntimeCallsApi
import io.novafoundation.nova.runtime.call.RuntimeCallsApi
import io.novafoundation.nova.runtime.ext.currencyId
import io.novafoundation.nova.runtime.ext.requireOrml
import io.novafoundation.nova.runtime.multiNetwork.ChainRegistry
import io.novafoundation.nova.runtime.multiNetwork.chain.model.Chain
import io.novafoundation.nova.runtime.multiNetwork.getRuntime
import io.novafoundation.nova.runtime.network.rpc.RpcCalls
import io.novafoundation.nova.runtime.repository.ChainStateRepository
import io.novafoundation.nova.runtime.repository.currentRemoteBlockNumberFlow
import io.novasama.substrate_sdk_android.runtime.AccountId
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.emptyFlow
import kotlinx.coroutines.flow.flow
import kotlinx.coroutines.flow.map
import kotlinx.coroutines.flow.mapNotNull
import java.math.BigInteger
import javax.inject.Inject
/**
* Balance source implementation for Hydration ERC20 tokens that are "mostly exposed" via Orml on Substrate side
* In particular, we can transact, listen for events, but cannot subscribe storage for balance updates
* as balance stays in the smart-contract storage on evm-side. Instead, we use runtime api to poll update once a block
*/
@FeatureScope
class HydrationEvmOrmlAssetBalance @Inject constructor(
private val defaultDelegate: OrmlAssetBalance,
private val runtimeCallsApi: MultiChainRuntimeCallsApi,
private val chainRegistry: ChainRegistry,
private val chainStateRepository: ChainStateRepository,
private val assetCache: AssetCache,
private val rpcCalls: RpcCalls,
) : AssetBalance {
override suspend fun startSyncingBalanceLocks(
metaAccount: MetaAccount,
chain: Chain,
chainAsset: Chain.Asset,
accountId: AccountId,
subscriptionBuilder: SharedRequestsBuilder
): Flow<*> {
return emptyFlow<Nothing>()
}
override fun isSelfSufficient(chainAsset: Chain.Asset): Boolean {
return defaultDelegate.isSelfSufficient(chainAsset)
}
override suspend fun existentialDeposit(chainAsset: Chain.Asset): BigInteger {
return defaultDelegate.existentialDeposit(chainAsset)
}
override suspend fun queryAccountBalance(chain: Chain, chainAsset: Chain.Asset, accountId: AccountId): ChainAssetBalance {
return fetchBalance(chainAsset, accountId)
}
override suspend fun subscribeAccountBalanceUpdatePoint(chain: Chain, chainAsset: Chain.Asset, accountId: AccountId): Flow<TransferableBalanceUpdatePoint> {
return balanceUpdateFlow(chainAsset, accountId, subscriptionBuilder = null)
.mapNotNull { (it.update as? BalanceSyncUpdate.CauseFetchable)?.blockHash }
.map(::TransferableBalanceUpdatePoint)
}
override suspend fun startSyncingBalance(
chain: Chain,
chainAsset: Chain.Asset,
metaAccount: MetaAccount,
accountId: AccountId,
subscriptionBuilder: SharedRequestsBuilder
): Flow<BalanceSyncUpdate> {
return balanceUpdateFlow(chainAsset, accountId, subscriptionBuilder).map { (balance, syncUpdate) ->
assetCache.updateFromChainBalance(metaAccount.id, balance)
syncUpdate
}
}
private suspend fun balanceUpdateFlow(
chainAsset: Chain.Asset,
accountId: AccountId,
subscriptionBuilder: SharedRequestsBuilder?
): Flow<BalancePollingUpdate> {
val blockNumberFlow = chainStateRepository.currentRemoteBlockNumberFlow(chainAsset.chainId, subscriptionBuilder)
return flow {
val initialBalance = fetchBalance(chainAsset, accountId)
val initialBalanceUpdate = BalancePollingUpdate(initialBalance, BalanceSyncUpdate.NoCause)
emit(initialBalanceUpdate)
var currentBalance = initialBalance
blockNumberFlow.collect { blockNumber ->
val newBalance = fetchBalance(chainAsset, accountId)
if (currentBalance != newBalance) {
currentBalance = newBalance
val balanceUpdatedAt = rpcCalls.getBlockHash(chainAsset.chainId, blockNumber)
val syncUpdate = BalanceSyncUpdate.CauseFetchable(balanceUpdatedAt)
val balanceUpdate = BalancePollingUpdate(newBalance, syncUpdate)
emit(balanceUpdate)
}
}
}
}
private suspend fun fetchBalance(chainAsset: Chain.Asset, accountId: AccountId): ChainAssetBalance {
return runtimeCallsApi.forChain(chainAsset.chainId).fetchBalance(chainAsset, accountId)
}
private suspend fun RuntimeCallsApi.fetchBalance(chainAsset: Chain.Asset, accountId: AccountId): ChainAssetBalance {
val runtime = chainRegistry.getRuntime(chainAsset.chainId)
val assetId = chainAsset.requireOrml().currencyId(runtime)
return call(
section = "CurrenciesApi",
method = "account",
arguments = mapOf(
"asset_id" to assetId,
"who" to accountId
),
returnBinding = { bindAssetBalance(it, chainAsset) }
)
}
private fun bindAssetBalance(decoded: Any?, chainAsset: Chain.Asset): ChainAssetBalance {
val asStruct = decoded.castToStruct()
return ChainAssetBalance(
chainAsset = chainAsset,
free = bindNumber(asStruct["free"]),
frozen = bindNumber(asStruct["frozen"]),
reserved = bindNumber(asStruct["reserved"]),
transferableMode = TransferableMode.REGULAR,
edCountingMode = EDCountingMode.TOTAL
)
}
private data class BalancePollingUpdate(
val assetBalance: ChainAssetBalance,
val update: BalanceSyncUpdate
)
}
@@ -0,0 +1,203 @@
package io.novafoundation.nova.feature_wallet_impl.data.network.blockchain.assets.balances.statemine
import android.util.Log
import io.novafoundation.nova.common.data.network.runtime.binding.AccountBalance
import io.novafoundation.nova.common.utils.LOG_TAG
import io.novafoundation.nova.common.utils.decodeValue
import io.novafoundation.nova.core.updater.SharedRequestsBuilder
import io.novafoundation.nova.core_db.model.AssetLocal.EDCountingModeLocal
import io.novafoundation.nova.core_db.model.AssetLocal.TransferableModeLocal
import io.novafoundation.nova.feature_account_api.domain.model.MetaAccount
import io.novafoundation.nova.feature_wallet_api.data.cache.AssetCache
import io.novafoundation.nova.feature_wallet_api.data.network.blockhain.assets.balances.AssetBalance
import io.novafoundation.nova.feature_wallet_api.data.network.blockhain.assets.balances.BalanceSyncUpdate
import io.novafoundation.nova.feature_wallet_api.data.network.blockhain.assets.balances.model.ChainAssetBalance
import io.novafoundation.nova.feature_wallet_api.data.network.blockhain.assets.balances.model.StatemineAssetDetails
import io.novafoundation.nova.feature_wallet_api.data.network.blockhain.assets.balances.model.TransferableBalanceUpdatePoint
import io.novafoundation.nova.feature_wallet_api.data.network.blockhain.assets.balances.model.transfersFrozen
import io.novafoundation.nova.feature_wallet_api.data.repository.StatemineAssetsRepository
import io.novafoundation.nova.feature_wallet_api.domain.model.BalanceLock
import io.novafoundation.nova.feature_wallet_impl.data.network.blockchain.assets.common.bindAssetAccountOrEmpty
import io.novafoundation.nova.feature_wallet_impl.data.network.blockchain.assets.common.statemineModule
import io.novafoundation.nova.runtime.ext.requireStatemine
import io.novafoundation.nova.runtime.multiNetwork.ChainRegistry
import io.novafoundation.nova.runtime.multiNetwork.chain.model.Chain
import io.novafoundation.nova.runtime.multiNetwork.chain.model.prepareIdForEncoding
import io.novafoundation.nova.runtime.multiNetwork.getRuntime
import io.novafoundation.nova.runtime.storage.source.StorageDataSource
import io.novasama.substrate_sdk_android.runtime.AccountId
import io.novasama.substrate_sdk_android.runtime.metadata.storage
import io.novasama.substrate_sdk_android.runtime.metadata.storageKey
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.catch
import kotlinx.coroutines.flow.combine
import kotlinx.coroutines.flow.emptyFlow
import kotlinx.coroutines.flow.map
import java.math.BigInteger
// TODO Migrate low-level subscription to storage to AssetsApi
class StatemineAssetBalance(
private val chainRegistry: ChainRegistry,
private val assetCache: AssetCache,
private val remoteStorage: StorageDataSource,
private val statemineAssetsRepository: StatemineAssetsRepository,
) : AssetBalance {
override suspend fun startSyncingBalanceLocks(
metaAccount: MetaAccount,
chain: Chain,
chainAsset: Chain.Asset,
accountId: AccountId,
subscriptionBuilder: SharedRequestsBuilder
): Flow<List<BalanceLock>> {
return emptyFlow()
}
override fun isSelfSufficient(chainAsset: Chain.Asset): Boolean {
return runCatching {
chainAsset.requireStatemine().isSufficient
}.getOrDefault(false)
}
override suspend fun existentialDeposit(chainAsset: Chain.Asset): BigInteger {
return runCatching {
queryAssetDetails(chainAsset).minimumBalance
}.getOrElse { error ->
Log.e(LOG_TAG, "Failed to query existential deposit for ${chainAsset.symbol}: ${error.message}")
BigInteger.ZERO
}
}
override suspend fun queryAccountBalance(chain: Chain, chainAsset: Chain.Asset, accountId: AccountId): ChainAssetBalance {
return runCatching {
val statemineType = chainAsset.requireStatemine()
val assetAccount = remoteStorage.query(chain.id) {
val encodableId = statemineType.prepareIdForEncoding(runtime)
runtime.metadata.statemineModule(statemineType).storage("Account").query(
encodableId,
accountId,
binding = ::bindAssetAccountOrEmpty
)
}
val accountBalance = assetAccount.toAccountBalance()
ChainAssetBalance.default(chainAsset, accountBalance)
}.getOrElse { error ->
Log.e(LOG_TAG, "Failed to query balance for ${chainAsset.symbol} on ${chain.name}: ${error.message}")
ChainAssetBalance.fromFree(chainAsset, BigInteger.ZERO)
}
}
override suspend fun subscribeAccountBalanceUpdatePoint(
chain: Chain,
chainAsset: Chain.Asset,
accountId: AccountId,
): Flow<TransferableBalanceUpdatePoint> {
return runCatching {
val statemineType = chainAsset.requireStatemine()
remoteStorage.subscribe(chain.id) {
val encodableId = statemineType.prepareIdForEncoding(runtime)
runtime.metadata.statemineModule(statemineType).storage("Account").observeWithRaw(
encodableId,
accountId,
binding = ::bindAssetAccountOrEmpty
).map {
TransferableBalanceUpdatePoint(it.at!!)
}
}.catch { error ->
Log.e(LOG_TAG, "Balance subscription failed for ${chainAsset.symbol} on ${chain.name}: ${error.message}")
}
}.getOrElse { error ->
Log.e(LOG_TAG, "Failed to setup balance subscription for ${chainAsset.symbol} on ${chain.name}: ${error.message}")
emptyFlow()
}
}
private fun AssetAccount.toAccountBalance(): AccountBalance {
val frozenBalance = if (isBalanceFrozen) {
balance
} else {
BigInteger.ZERO
}
return AccountBalance(
free = balance,
reserved = BigInteger.ZERO,
frozen = frozenBalance
)
}
override suspend fun startSyncingBalance(
chain: Chain,
chainAsset: Chain.Asset,
metaAccount: MetaAccount,
accountId: AccountId,
subscriptionBuilder: SharedRequestsBuilder
): Flow<BalanceSyncUpdate> {
return runCatching {
val runtime = chainRegistry.getRuntime(chain.id)
val statemineType = chainAsset.requireStatemine()
val encodableAssetId = statemineType.prepareIdForEncoding(runtime)
val module = runtime.metadata.statemineModule(statemineType)
val assetAccountStorage = module.storage("Account")
val assetAccountKey = assetAccountStorage.storageKey(runtime, encodableAssetId, accountId)
val assetDetailsFlow = statemineAssetsRepository.subscribeAndSyncAssetDetails(chain.id, statemineType, subscriptionBuilder)
combine(
subscriptionBuilder.subscribe(assetAccountKey),
assetDetailsFlow.map { it.status.transfersFrozen }
) { balanceStorageChange, isAssetFrozen ->
val assetAccountDecoded = assetAccountStorage.decodeValue(balanceStorageChange.value, runtime)
val assetAccount = bindAssetAccountOrEmpty(assetAccountDecoded)
val assetChanged = updateAssetBalance(metaAccount.id, chainAsset, isAssetFrozen, assetAccount)
if (assetChanged) {
BalanceSyncUpdate.CauseFetchable(balanceStorageChange.block)
} else {
BalanceSyncUpdate.NoCause
}
}.catch { error ->
Log.e(LOG_TAG, "Balance sync failed for ${chainAsset.symbol} on ${chain.name}: ${error.message}")
emit(BalanceSyncUpdate.NoCause)
}
}.getOrElse { error ->
Log.e(LOG_TAG, "Failed to start balance sync for ${chainAsset.symbol} on ${chain.name}: ${error.message}")
emptyFlow()
}
}
private suspend fun queryAssetDetails(chainAsset: Chain.Asset): StatemineAssetDetails {
val statemineType = chainAsset.requireStatemine()
return statemineAssetsRepository.getAssetDetails(chainAsset.chainId, statemineType)
}
private suspend fun updateAssetBalance(
metaId: Long,
chainAsset: Chain.Asset,
isAssetFrozen: Boolean,
assetAccount: AssetAccount
) = assetCache.updateAsset(metaId, chainAsset) {
val frozenBalance = if (isAssetFrozen || assetAccount.isBalanceFrozen) {
assetAccount.balance
} else {
BigInteger.ZERO
}
val freeBalance = assetAccount.balance
it.copy(
frozenInPlanks = frozenBalance,
freeInPlanks = freeBalance,
transferableMode = TransferableModeLocal.REGULAR,
edCountingMode = EDCountingModeLocal.TOTAL,
)
}
}
@@ -0,0 +1,86 @@
package io.novafoundation.nova.feature_wallet_impl.data.network.blockchain.assets.balances.statemine
import io.novafoundation.nova.common.data.network.runtime.binding.UseCaseBinding
import io.novafoundation.nova.common.data.network.runtime.binding.bindAccountIdKey
import io.novafoundation.nova.common.data.network.runtime.binding.bindBoolean
import io.novafoundation.nova.common.data.network.runtime.binding.bindCollectionEnum
import io.novafoundation.nova.common.data.network.runtime.binding.bindNumber
import io.novafoundation.nova.common.data.network.runtime.binding.cast
import io.novafoundation.nova.common.data.network.runtime.binding.incompatible
import io.novafoundation.nova.feature_wallet_api.data.network.blockhain.assets.balances.model.StatemineAssetDetails
import io.novafoundation.nova.feature_wallet_impl.data.network.blockchain.assets.balances.statemine.AssetAccount.AccountStatus
import io.novasama.substrate_sdk_android.runtime.definitions.types.composite.Struct
import java.math.BigInteger
@UseCaseBinding
fun bindAssetDetails(decoded: Any?): StatemineAssetDetails {
val dynamicInstance = decoded.cast<Struct.Instance>()
return StatemineAssetDetails(
status = bindAssetStatus(dynamicInstance),
isSufficient = bindBoolean(dynamicInstance["isSufficient"]),
minimumBalance = bindNumber(dynamicInstance["minBalance"]),
issuer = bindAccountIdKey(dynamicInstance["issuer"])
)
}
private fun bindAssetStatus(assetStruct: Struct.Instance): StatemineAssetDetails.Status {
return when {
assetStruct.get<Any>("isFrozen") != null -> bindIsFrozen(bindBoolean(assetStruct["isFrozen"]))
assetStruct.get<Any>("status") != null -> bindCollectionEnum(assetStruct["status"])
else -> incompatible()
}
}
private fun bindIsFrozen(isFrozen: Boolean): StatemineAssetDetails.Status {
return if (isFrozen) StatemineAssetDetails.Status.Frozen else StatemineAssetDetails.Status.Live
}
class AssetAccount(
val balance: BigInteger,
val status: AccountStatus
) {
enum class AccountStatus {
Liquid, Frozen, Blocked
}
companion object {
fun empty() = AssetAccount(
balance = BigInteger.ZERO,
status = AccountStatus.Liquid
)
}
}
val AssetAccount.isBalanceFrozen: Boolean
get() = status == AccountStatus.Blocked || status == AccountStatus.Frozen
val AssetAccount.canAcceptFunds: Boolean
get() = status != AccountStatus.Blocked
@UseCaseBinding
fun bindAssetAccount(decoded: Any): AssetAccount {
val dynamicInstance = decoded.cast<Struct.Instance>()
val status = when {
// old version of assets pallet - isFrozen flag
"isFrozen" in dynamicInstance.mapping -> {
val isFrozen = bindBoolean(dynamicInstance["isFrozen"])
if (isFrozen) AccountStatus.Frozen else AccountStatus.Liquid
}
// new version of assets pallet - status enum
"status" in dynamicInstance.mapping -> {
bindCollectionEnum(dynamicInstance["status"])
}
else -> incompatible()
}
return AssetAccount(
balance = bindNumber(dynamicInstance["balance"]),
status = status,
)
}
@@ -0,0 +1,28 @@
package io.novafoundation.nova.feature_wallet_impl.data.network.blockchain.assets.balances.utility
import io.novafoundation.nova.common.utils.balances
import io.novafoundation.nova.feature_wallet_impl.data.network.blockchain.assets.balances.BlockchainLock
import io.novafoundation.nova.feature_wallet_impl.data.network.blockchain.assets.balances.bindBalanceFreezes
import io.novafoundation.nova.feature_wallet_impl.data.network.blockchain.assets.balances.bindBalanceLocks
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 BalancesRuntimeApi(override val module: Module) : QueryableModule
context(StorageQueryContext)
val RuntimeMetadata.balances: BalancesRuntimeApi
get() = BalancesRuntimeApi(balances())
context(StorageQueryContext)
val BalancesRuntimeApi.locks: QueryableStorageEntry1<AccountId, List<BlockchainLock>>
get() = storage1("Locks", binding = { decoded, _ -> bindBalanceLocks(decoded) })
context(StorageQueryContext)
val BalancesRuntimeApi.freezes: QueryableStorageEntry1<AccountId, List<BlockchainLock>>
get() = storage1("Freezes", binding = { decoded, _ -> bindBalanceFreezes(decoded) })
@@ -0,0 +1,220 @@
package io.novafoundation.nova.feature_wallet_impl.data.network.blockchain.assets.balances.utility
import android.util.Log
import io.novafoundation.nova.common.data.network.runtime.binding.bindList
import io.novafoundation.nova.common.data.network.runtime.binding.bindNumber
import io.novafoundation.nova.common.data.network.runtime.binding.castToDictEnum
import io.novafoundation.nova.common.data.network.runtime.binding.castToStruct
import io.novafoundation.nova.common.utils.LOG_TAG
import io.novafoundation.nova.common.utils.balances
import io.novafoundation.nova.common.utils.decodeValue
import io.novafoundation.nova.common.utils.metadata
import io.novafoundation.nova.common.utils.numberConstant
import io.novafoundation.nova.common.utils.system
import io.novafoundation.nova.core.updater.SharedRequestsBuilder
import io.novafoundation.nova.core_db.dao.HoldsDao
import io.novafoundation.nova.core_db.dao.LockDao
import io.novafoundation.nova.core_db.model.BalanceHoldLocal
import io.novafoundation.nova.feature_account_api.domain.model.MetaAccount
import io.novafoundation.nova.feature_wallet_api.data.cache.AssetCache
import io.novafoundation.nova.feature_wallet_api.data.cache.bindAccountInfoOrDefault
import io.novafoundation.nova.feature_wallet_api.data.cache.updateAsset
import io.novafoundation.nova.feature_wallet_api.data.network.blockhain.assets.balances.AssetBalance
import io.novafoundation.nova.feature_wallet_api.data.network.blockhain.assets.balances.BalanceSyncUpdate
import io.novafoundation.nova.feature_wallet_api.data.network.blockhain.assets.balances.model.ChainAssetBalance
import io.novafoundation.nova.feature_wallet_api.data.network.blockhain.assets.balances.model.TransferableBalanceUpdatePoint
import io.novafoundation.nova.feature_wallet_api.data.network.blockhain.assets.balances.model.toChainAssetBalance
import io.novafoundation.nova.feature_wallet_api.data.network.blockhain.types.Balance
import io.novafoundation.nova.feature_wallet_api.data.repository.AccountInfoRepository
import io.novafoundation.nova.feature_wallet_api.domain.model.BalanceHold
import io.novafoundation.nova.feature_wallet_impl.data.network.blockchain.assets.balances.updateLocks
import io.novafoundation.nova.runtime.ext.utilityAsset
import io.novafoundation.nova.runtime.multiNetwork.ChainRegistry
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.getRuntime
import io.novafoundation.nova.runtime.storage.source.StorageDataSource
import io.novafoundation.nova.runtime.storage.typed.account
import io.novafoundation.nova.runtime.storage.typed.system
import io.novasama.substrate_sdk_android.runtime.AccountId
import io.novasama.substrate_sdk_android.runtime.metadata.storage
import io.novasama.substrate_sdk_android.runtime.metadata.storageKey
import io.novasama.substrate_sdk_android.runtime.metadata.storageOrNull
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.catch
import kotlinx.coroutines.flow.combine
import kotlinx.coroutines.flow.emptyFlow
import kotlinx.coroutines.flow.map
import java.math.BigInteger
class NativeAssetBalance(
private val chainRegistry: ChainRegistry,
private val assetCache: AssetCache,
private val accountInfoRepository: AccountInfoRepository,
private val remoteStorage: StorageDataSource,
private val lockDao: LockDao,
private val holdsDao: HoldsDao,
) : AssetBalance {
override suspend fun startSyncingBalanceLocks(
metaAccount: MetaAccount,
chain: Chain,
chainAsset: Chain.Asset,
accountId: AccountId,
subscriptionBuilder: SharedRequestsBuilder
): Flow<*> {
return runCatching {
remoteStorage.subscribe(chain.id, subscriptionBuilder) {
combine(
metadata.balances.locks.observe(accountId),
metadata.balances.freezes.observe(accountId)
) { locks, freezes ->
val all = locks.orEmpty() + freezes.orEmpty()
lockDao.updateLocks(all, metaAccount.id, chain.id, chainAsset.id)
}
}.catch { error ->
Log.e(LOG_TAG, "Balance locks sync failed for ${chainAsset.symbol} on ${chain.name}: ${error.message}")
}
}.getOrElse { error ->
Log.e(LOG_TAG, "Failed to start balance locks sync for ${chainAsset.symbol} on ${chain.name}: ${error.message}")
emptyFlow()
}
}
override suspend fun startSyncingBalanceHolds(
metaAccount: MetaAccount,
chain: Chain,
chainAsset: Chain.Asset,
accountId: AccountId,
subscriptionBuilder: SharedRequestsBuilder
): Flow<*> {
return runCatching {
val runtime = chainRegistry.getRuntime(chain.id)
val storage = runtime.metadata.balances().storageOrNull("Holds") ?: return emptyFlow<Nothing>()
val key = storage.storageKey(runtime, accountId)
subscriptionBuilder.subscribe(key)
.map { change ->
val holds = bindBalanceHolds(storage.decodeValue(change.value, runtime)).orEmpty()
holdsDao.updateHolds(holds, metaAccount.id, chain.id, chainAsset.id)
}
.catch { error ->
Log.e(LOG_TAG, "Balance holds sync failed for ${chainAsset.symbol} on ${chain.name}: ${error.message}")
}
}.getOrElse { error ->
Log.e(LOG_TAG, "Failed to start balance holds sync for ${chainAsset.symbol} on ${chain.name}: ${error.message}")
emptyFlow()
}
}
override fun isSelfSufficient(chainAsset: Chain.Asset): Boolean {
return true
}
override suspend fun existentialDeposit(chainAsset: Chain.Asset): BigInteger {
return runCatching {
val runtime = chainRegistry.getRuntime(chainAsset.chainId)
runtime.metadata.balances().numberConstant("ExistentialDeposit", runtime)
}.getOrElse { error ->
Log.e(LOG_TAG, "Failed to query existential deposit for ${chainAsset.symbol}: ${error.message}")
BigInteger.ZERO
}
}
override suspend fun queryAccountBalance(chain: Chain, chainAsset: Chain.Asset, accountId: AccountId): ChainAssetBalance {
return runCatching {
accountInfoRepository.getAccountInfo(chain.id, accountId).data.toChainAssetBalance(chainAsset)
}.getOrElse { error ->
Log.e(LOG_TAG, "Failed to query balance for ${chainAsset.symbol} on ${chain.name}: ${error.message}")
ChainAssetBalance.fromFree(chainAsset, BigInteger.ZERO)
}
}
override suspend fun subscribeAccountBalanceUpdatePoint(
chain: Chain,
chainAsset: Chain.Asset,
accountId: AccountId,
): Flow<TransferableBalanceUpdatePoint> {
return runCatching {
remoteStorage.subscribe(chain.id) {
metadata.system.account.observeWithRaw(accountId).map {
TransferableBalanceUpdatePoint(it.at!!)
}
}.catch { error ->
Log.e(LOG_TAG, "Balance subscription failed for ${chainAsset.symbol} on ${chain.name}: ${error.message}")
}
}.getOrElse { error ->
Log.e(LOG_TAG, "Failed to setup balance subscription for ${chainAsset.symbol} on ${chain.name}: ${error.message}")
emptyFlow()
}
}
override suspend fun startSyncingBalance(
chain: Chain,
chainAsset: Chain.Asset,
metaAccount: MetaAccount,
accountId: AccountId,
subscriptionBuilder: SharedRequestsBuilder
): Flow<BalanceSyncUpdate> {
val runtime = chainRegistry.getRuntime(chain.id)
val key = try {
runtime.metadata.system().storage("Account").storageKey(runtime, accountId)
} catch (e: Exception) {
Log.e(LOG_TAG, "Failed to construct account storage key: ${e.message} in ${chain.name}")
return emptyFlow()
}
return subscriptionBuilder.subscribe(key)
.map { change ->
val accountInfo = bindAccountInfoOrDefault(change.value, runtime)
val assetChanged = assetCache.updateAsset(metaAccount.id, chain.utilityAsset, accountInfo)
if (assetChanged) {
BalanceSyncUpdate.CauseFetchable(change.block)
} else {
BalanceSyncUpdate.NoCause
}
}
.catch { error ->
Log.e(LOG_TAG, "Balance sync failed for ${chainAsset.symbol} on ${chain.name}: ${error.message}")
emit(BalanceSyncUpdate.NoCause)
}
}
private fun bindBalanceHolds(dynamicInstance: Any?): List<BlockchainHold>? {
if (dynamicInstance == null) return null
return bindList(dynamicInstance) {
BlockchainHold(
id = bindHoldId(it.castToStruct()["id"]),
amount = bindNumber(it.castToStruct()["amount"])
)
}
}
private fun bindHoldId(id: Any?): BalanceHold.HoldId {
val module = id.castToDictEnum()
val reason = module.value.castToDictEnum()
return BalanceHold.HoldId(module.name, reason.name)
}
private suspend fun HoldsDao.updateHolds(holds: List<BlockchainHold>, metaId: Long, chainId: ChainId, chainAssetId: ChainAssetId) {
val balanceLocksLocal = holds.map {
BalanceHoldLocal(
metaId = metaId,
chainId = chainId,
assetId = chainAssetId,
id = BalanceHoldLocal.HoldIdLocal(module = it.id.module, reason = it.id.reason),
amount = it.amount
)
}
updateHolds(balanceLocksLocal, metaId, chainId, chainAssetId)
}
private class BlockchainHold(val id: BalanceHold.HoldId, val amount: Balance)
}
@@ -0,0 +1,14 @@
package io.novafoundation.nova.feature_wallet_impl.data.network.blockchain.assets.common
import io.novafoundation.nova.feature_wallet_impl.data.network.blockchain.assets.balances.statemine.AssetAccount
import io.novafoundation.nova.feature_wallet_impl.data.network.blockchain.assets.balances.statemine.bindAssetAccount
import io.novafoundation.nova.runtime.ext.palletNameOrDefault
import io.novafoundation.nova.runtime.multiNetwork.chain.model.Chain
import io.novasama.substrate_sdk_android.runtime.metadata.RuntimeMetadata
import io.novasama.substrate_sdk_android.runtime.metadata.module
fun RuntimeMetadata.statemineModule(statemineType: Chain.Asset.Type.Statemine) = module(statemineType.palletNameOrDefault())
fun bindAssetAccountOrEmpty(decoded: Any?): AssetAccount {
return decoded?.let(::bindAssetAccount) ?: AssetAccount.empty()
}
@@ -0,0 +1,37 @@
package io.novafoundation.nova.feature_wallet_impl.data.network.blockchain.assets.common.orml
import io.novafoundation.nova.common.di.scope.FeatureScope
import io.novafoundation.nova.feature_wallet_api.data.network.blockhain.assets.AssetSource
import io.novafoundation.nova.feature_wallet_impl.data.network.blockchain.assets.StaticAssetSource
import io.novafoundation.nova.feature_wallet_impl.data.network.blockchain.assets.balances.orml.OrmlAssetBalance
import io.novafoundation.nova.feature_wallet_impl.data.network.blockchain.assets.balances.orml.hydrationEvm.HydrationEvmOrmlAssetBalance
import io.novafoundation.nova.feature_wallet_impl.data.network.blockchain.assets.history.orml.OrmlAssetHistory
import io.novafoundation.nova.feature_wallet_impl.data.network.blockchain.assets.transfers.orml.OrmlAssetTransfers
import io.novafoundation.nova.feature_wallet_impl.data.network.blockchain.assets.transfers.orml.hydrationEvm.HydrationEvmAssetTransfers
import io.novafoundation.nova.runtime.multiNetwork.chain.model.Chain
import javax.inject.Inject
@FeatureScope
class OrmlAssetSourceFactory @Inject constructor(
defaultBalance: OrmlAssetBalance,
defaultHistory: OrmlAssetHistory,
defaultTransfers: OrmlAssetTransfers,
hydrationEvmOrmlAssetBalance: HydrationEvmOrmlAssetBalance,
hydrationEvmAssetTransfers: HydrationEvmAssetTransfers
) {
private val defaultSource = StaticAssetSource(defaultTransfers, defaultBalance, defaultHistory)
private val hydrationEvmSource = StaticAssetSource(hydrationEvmAssetTransfers, hydrationEvmOrmlAssetBalance, defaultHistory)
fun allSources(): List<AssetSource> {
return listOf(defaultSource, hydrationEvmSource)
}
fun getSourceBySubtype(subType: Chain.Asset.Type.Orml.SubType): AssetSource {
return when (subType) {
Chain.Asset.Type.Orml.SubType.DEFAULT -> defaultSource
Chain.Asset.Type.Orml.SubType.HYDRATION_EVM -> hydrationEvmSource
}
}
}
@@ -0,0 +1,12 @@
package io.novafoundation.nova.feature_wallet_impl.data.network.blockchain.assets.events
import io.novafoundation.nova.feature_wallet_api.data.network.blockhain.assets.events.AssetEventDetector
import io.novafoundation.nova.feature_wallet_api.data.network.blockhain.assets.events.model.DepositEvent
import io.novasama.substrate_sdk_android.runtime.definitions.types.generics.GenericEvent
class UnsupportedEventDetector : AssetEventDetector {
override fun detectDeposit(event: GenericEvent.Instance): DepositEvent? {
return null
}
}
@@ -0,0 +1,69 @@
package io.novafoundation.nova.feature_wallet_impl.data.network.blockchain.assets.events.evmErc20
import io.novafoundation.nova.common.address.intoKey
import io.novafoundation.nova.common.data.network.runtime.binding.bindAccountIdKey
import io.novafoundation.nova.common.data.network.runtime.binding.bindByteArray
import io.novafoundation.nova.common.data.network.runtime.binding.bindList
import io.novafoundation.nova.common.data.network.runtime.binding.castToStruct
import io.novafoundation.nova.common.di.scope.FeatureScope
import io.novafoundation.nova.common.utils.Modules
import io.novafoundation.nova.common.utils.ethereumAddressToAccountId
import io.novafoundation.nova.common.utils.instanceOf
import io.novafoundation.nova.feature_wallet_api.data.network.blockhain.assets.events.AssetEventDetector
import io.novafoundation.nova.feature_wallet_api.data.network.blockhain.assets.events.model.DepositEvent
import io.novafoundation.nova.runtime.ethereum.contract.erc20.Erc20Queries
import io.novafoundation.nova.runtime.ext.requireErc20
import io.novafoundation.nova.runtime.multiNetwork.chain.model.Chain
import io.novasama.substrate_sdk_android.extensions.toHexString
import io.novasama.substrate_sdk_android.runtime.definitions.types.generics.GenericEvent
import javax.inject.Inject
@FeatureScope
class EvmErc20EventDetectorFactory @Inject constructor() {
fun create(chainAsset: Chain.Asset): AssetEventDetector {
return EvmErc20EventDetector(chainAsset.requireErc20())
}
}
class EvmErc20EventDetector(
private val erc20AssetType: Chain.Asset.Type.EvmErc20
) : AssetEventDetector {
override fun detectDeposit(event: GenericEvent.Instance): DepositEvent? {
return parseEvmLogEvent(event)?.toDepositEvent()
}
private fun parseEvmLogEvent(event: GenericEvent.Instance): Erc20Queries.Transfer? {
if (!event.instanceOf(Modules.EVM, "Log")) return null
val args = event.arguments.first().castToStruct()
val address = bindAccountIdKey(args["address"])
val contractAddress = erc20AssetType.contractAddress.ethereumAddressToAccountId().intoKey()
if (contractAddress != address) return null
val topics = bindList(args["topics"], ::bindHexString)
val eventSignature = topics[0]
if (eventSignature != Erc20Queries.transferEventSignature()) return null
return Erc20Queries.parseTransferEvent(
topic1 = topics[1],
topic2 = topics[2],
data = bindHexString(args["data"])
)
}
private fun Erc20Queries.Transfer.toDepositEvent(): DepositEvent {
return DepositEvent(
destination = to.value.ethereumAddressToAccountId().intoKey(),
amount = amount.value
)
}
private fun bindHexString(decoded: Any?): String {
return bindByteArray(decoded).toHexString(withPrefix = true)
}
}
@@ -0,0 +1,55 @@
package io.novafoundation.nova.feature_wallet_impl.data.network.blockchain.assets.events.orml
import io.novafoundation.nova.common.data.network.runtime.binding.bindAccountIdKey
import io.novafoundation.nova.common.data.network.runtime.binding.bindNumber
import io.novafoundation.nova.common.utils.Modules
import io.novafoundation.nova.common.utils.instanceOf
import io.novafoundation.nova.feature_wallet_api.data.network.blockhain.assets.events.AssetEventDetector
import io.novafoundation.nova.feature_wallet_api.data.network.blockhain.assets.events.model.DepositEvent
import io.novafoundation.nova.runtime.ext.requireOrml
import io.novafoundation.nova.runtime.multiNetwork.ChainRegistry
import io.novafoundation.nova.runtime.multiNetwork.chain.model.Chain
import io.novafoundation.nova.runtime.multiNetwork.getRuntime
import io.novasama.substrate_sdk_android.extensions.requireHexPrefix
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.definitions.types.toHexUntyped
class OrmlAssetEventDetectorFactory(
private val chainRegistry: ChainRegistry,
) {
suspend fun create(chainAsset: Chain.Asset): AssetEventDetector {
val ormlType = chainAsset.requireOrml()
val runtime = chainRegistry.getRuntime(chainAsset.chainId)
return OrmlAssetEventDetector(runtime, ormlType)
}
}
private class OrmlAssetEventDetector(
private val runtimeSnapshot: RuntimeSnapshot,
private val ormlType: Chain.Asset.Type.Orml,
) : AssetEventDetector {
private val targetCurrencyId = ormlType.currencyIdScale.requireHexPrefix()
override fun detectDeposit(event: GenericEvent.Instance): DepositEvent? {
return detectTokensDeposited(event)
}
private fun detectTokensDeposited(event: GenericEvent.Instance): DepositEvent? {
if (!event.instanceOf(Modules.TOKENS, "Deposited")) return null
val (currencyId, who, amount) = event.arguments
val currencyIdType = runtimeSnapshot.typeRegistry[ormlType.currencyIdType]!!
val currencyIdEncoded = currencyIdType.toHexUntyped(runtimeSnapshot, currencyId).requireHexPrefix()
if (currencyIdEncoded != targetCurrencyId) return null
return DepositEvent(
destination = bindAccountIdKey(who),
amount = bindNumber(amount)
)
}
}
@@ -0,0 +1,73 @@
package io.novafoundation.nova.feature_wallet_impl.data.network.blockchain.assets.events.statemine
import io.novafoundation.nova.common.data.network.runtime.binding.bindAccountIdKey
import io.novafoundation.nova.common.data.network.runtime.binding.bindNumber
import io.novafoundation.nova.common.utils.instanceOf
import io.novafoundation.nova.feature_wallet_api.data.network.blockhain.assets.events.AssetEventDetector
import io.novafoundation.nova.feature_wallet_api.data.network.blockhain.assets.events.model.DepositEvent
import io.novafoundation.nova.runtime.ext.palletNameOrDefault
import io.novafoundation.nova.runtime.ext.requireStatemine
import io.novafoundation.nova.runtime.multiNetwork.ChainRegistry
import io.novafoundation.nova.runtime.multiNetwork.chain.model.Chain
import io.novafoundation.nova.runtime.multiNetwork.chain.model.StatemineAssetId
import io.novafoundation.nova.runtime.multiNetwork.getRuntime
import io.novasama.substrate_sdk_android.extensions.requireHexPrefix
import io.novasama.substrate_sdk_android.runtime.RuntimeSnapshot
import io.novasama.substrate_sdk_android.runtime.definitions.types.RuntimeType
import io.novasama.substrate_sdk_android.runtime.definitions.types.generics.GenericEvent
import io.novasama.substrate_sdk_android.runtime.definitions.types.toHexUntyped
import java.math.BigInteger
class StatemineAssetEventDetectorFactory(
private val chainRegistry: ChainRegistry,
) {
suspend fun create(chainAsset: Chain.Asset): AssetEventDetector {
val assetType = chainAsset.requireStatemine()
val runtime = chainRegistry.getRuntime(chainAsset.chainId)
return StatemineAssetEventDetector(runtime, assetType)
}
}
class StatemineAssetEventDetector(
private val runtimeSnapshot: RuntimeSnapshot,
private val assetType: Chain.Asset.Type.Statemine,
) : AssetEventDetector {
private val targetAssetId = assetType.id.stringAssetId()
override fun detectDeposit(event: GenericEvent.Instance): DepositEvent? {
return detectTokensDeposited(event)
}
private fun detectTokensDeposited(event: GenericEvent.Instance): DepositEvent? {
if (!event.instanceOf(assetType.palletNameOrDefault(), "Issued")) return null
val (assetId, who, amount) = event.arguments
val assetIdType = event.event.arguments.first()!!
val assetIdAsString = decodedAssetItToString(assetId, assetIdType)
if (assetIdAsString != targetAssetId) return null
return DepositEvent(
destination = bindAccountIdKey(who),
amount = bindNumber(amount)
)
}
private fun decodedAssetItToString(assetId: Any?, assetIdType: RuntimeType<*, *>): String {
return if (assetId is BigInteger) {
assetId.toString()
} else {
assetIdType.toHexUntyped(runtimeSnapshot, assetId).requireHexPrefix()
}
}
private fun StatemineAssetId.stringAssetId(): String {
return when (this) {
is StatemineAssetId.Number -> value.toString()
is StatemineAssetId.ScaleEncoded -> scaleHex
}
}
}
@@ -0,0 +1,39 @@
package io.novafoundation.nova.feature_wallet_impl.data.network.blockchain.assets.events.utility
import io.novafoundation.nova.common.data.network.runtime.binding.bindAccountIdKey
import io.novafoundation.nova.common.data.network.runtime.binding.bindNumber
import io.novafoundation.nova.common.utils.Modules
import io.novafoundation.nova.common.utils.instanceOf
import io.novafoundation.nova.feature_wallet_api.data.network.blockhain.assets.events.AssetEventDetector
import io.novafoundation.nova.feature_wallet_api.data.network.blockhain.assets.events.model.DepositEvent
import io.novasama.substrate_sdk_android.runtime.definitions.types.generics.GenericEvent
class NativeAssetEventDetector : AssetEventDetector {
override fun detectDeposit(event: GenericEvent.Instance): DepositEvent? {
return detectMinted(event)
?: detectBalancesDeposit(event)
}
private fun detectMinted(event: GenericEvent.Instance): DepositEvent? {
if (!event.instanceOf(Modules.BALANCES, "Minted")) return null
val (who, amount) = event.arguments
return DepositEvent(
destination = bindAccountIdKey(who),
amount = bindNumber(amount)
)
}
private fun detectBalancesDeposit(event: GenericEvent.Instance): DepositEvent? {
if (!event.instanceOf(Modules.BALANCES, "Deposit")) return null
val (who, amount) = event.arguments
return DepositEvent(
destination = bindAccountIdKey(who),
amount = bindNumber(amount)
)
}
}
@@ -0,0 +1,20 @@
package io.novafoundation.nova.feature_wallet_impl.data.network.blockchain.assets.history
import io.novafoundation.nova.feature_currency_api.domain.model.Currency
import io.novafoundation.nova.feature_wallet_api.data.network.blockhain.assets.history.AssetHistory
import io.novafoundation.nova.feature_wallet_api.data.repository.CoinPriceRepository
import io.novafoundation.nova.feature_wallet_api.data.repository.getAllCoinPriceHistory
import io.novafoundation.nova.feature_wallet_api.domain.model.HistoricalCoinRate
import io.novafoundation.nova.runtime.multiNetwork.chain.model.Chain
abstract class BaseAssetHistory(internal val coinPriceRepository: CoinPriceRepository) : AssetHistory {
protected suspend fun getPriceHistory(
chainAsset: Chain.Asset,
currency: Currency
): List<HistoricalCoinRate> {
return runCatching { coinPriceRepository.getAllCoinPriceHistory(chainAsset.priceId!!, currency) }
.getOrNull()
?: emptyList()
}
}
@@ -0,0 +1,109 @@
package io.novafoundation.nova.feature_wallet_impl.data.network.blockchain.assets.history
import io.novafoundation.nova.common.data.model.DataPage
import io.novafoundation.nova.common.data.model.PageOffset
import io.novafoundation.nova.feature_currency_api.domain.model.Currency
import io.novafoundation.nova.feature_wallet_api.data.repository.CoinPriceRepository
import io.novafoundation.nova.feature_wallet_api.domain.interfaces.TransactionFilter
import io.novafoundation.nova.feature_wallet_api.domain.model.Operation
import io.novafoundation.nova.feature_wallet_api.domain.model.satisfies
import io.novafoundation.nova.runtime.ext.externalApi
import io.novafoundation.nova.runtime.multiNetwork.chain.model.Chain
import io.novasama.substrate_sdk_android.runtime.AccountId
private const val FIRST_PAGE_INDEX = 1
private const val SECOND_PAGE_INDEX = 2
abstract class EvmAssetHistory(
coinPriceRepository: CoinPriceRepository
) : BaseAssetHistory(coinPriceRepository) {
abstract suspend fun fetchEtherscanOperations(
chain: Chain,
chainAsset: Chain.Asset,
accountId: AccountId,
apiUrl: String,
page: Int,
pageSize: Int,
currency: Currency
): List<Operation>
override suspend fun additionalFirstPageSync(
chain: Chain,
chainAsset: Chain.Asset,
accountId: AccountId,
page: Result<DataPage<Operation>>
) {
// nothing to do
}
override suspend fun getOperations(
pageSize: Int,
pageOffset: PageOffset.Loadable,
filters: Set<TransactionFilter>,
accountId: AccountId,
chain: Chain,
chainAsset: Chain.Asset,
currency: Currency
): DataPage<Operation> {
val evmTransfersApi = chain.evmTransfersApi() ?: return DataPage.empty()
return getOperationsEtherscan(
pageSize,
pageOffset,
filters,
accountId,
chain,
chainAsset,
evmTransfersApi.url,
currency
)
}
override suspend fun getSyncedPageOffset(
accountId: AccountId,
chain: Chain,
chainAsset: Chain.Asset
): PageOffset {
val evmTransfersApi = chain.evmTransfersApi()
return if (evmTransfersApi != null) {
PageOffset.Loadable.PageNumber(page = SECOND_PAGE_INDEX)
} else {
PageOffset.FullData
}
}
private suspend fun getOperationsEtherscan(
pageSize: Int,
pageOffset: PageOffset.Loadable,
filters: Set<TransactionFilter>,
accountId: AccountId,
chain: Chain,
chainAsset: Chain.Asset,
apiUrl: String,
currency: Currency
): DataPage<Operation> {
val page = when (pageOffset) {
PageOffset.Loadable.FirstPage -> FIRST_PAGE_INDEX
is PageOffset.Loadable.PageNumber -> pageOffset.page
else -> error("Etherscan requires page number pagination")
}
val operations = fetchEtherscanOperations(chain, chainAsset, accountId, apiUrl, page, pageSize, currency)
val newPageOffset = if (operations.size < pageSize) {
PageOffset.FullData
} else {
PageOffset.Loadable.PageNumber(page + 1)
}
val filteredOperations = operations.filter { it.type.satisfies(filters) }
return DataPage(newPageOffset, filteredOperations)
}
fun Chain.evmTransfersApi(): Chain.ExternalApi.Transfers.Evm? {
return externalApi()
}
}
@@ -0,0 +1,279 @@
package io.novafoundation.nova.feature_wallet_impl.data.network.blockchain.assets.history
import io.novafoundation.nova.common.data.model.CursorOrFull
import io.novafoundation.nova.common.data.model.DataPage
import io.novafoundation.nova.common.data.model.PageOffset
import io.novafoundation.nova.common.data.model.asCursorOrNull
import io.novafoundation.nova.common.utils.nullIfEmpty
import io.novafoundation.nova.common.utils.orZero
import io.novafoundation.nova.feature_currency_api.domain.model.Currency
import io.novafoundation.nova.feature_wallet_api.data.network.blockhain.assets.history.realtime.RealtimeHistoryUpdate
import io.novafoundation.nova.feature_wallet_api.data.network.blockhain.assets.history.realtime.substrate.SubstrateRealtimeOperationFetcher
import io.novafoundation.nova.feature_wallet_api.data.repository.CoinPriceRepository
import io.novafoundation.nova.feature_wallet_api.domain.interfaces.TransactionFilter
import io.novafoundation.nova.feature_wallet_api.domain.model.ChainAssetWithAmount
import io.novafoundation.nova.feature_wallet_api.domain.model.CoinRate
import io.novafoundation.nova.feature_wallet_api.domain.model.Operation
import io.novafoundation.nova.feature_wallet_api.domain.model.convertPlanks
import io.novafoundation.nova.feature_wallet_api.domain.model.findNearestCoinRate
import io.novafoundation.nova.feature_wallet_impl.data.network.model.AssetsBySubQueryId
import io.novafoundation.nova.feature_wallet_impl.data.network.model.assetsBySubQueryId
import io.novafoundation.nova.feature_wallet_impl.data.network.model.request.SubqueryHistoryRequest
import io.novafoundation.nova.feature_wallet_impl.data.network.model.response.SubqueryHistoryElementResponse
import io.novafoundation.nova.feature_wallet_impl.data.network.subquery.SubQueryOperationsApi
import io.novafoundation.nova.feature_wallet_impl.data.storage.TransferCursorStorage
import io.novafoundation.nova.runtime.ext.addressOf
import io.novafoundation.nova.runtime.ext.externalApi
import io.novafoundation.nova.runtime.ext.fullId
import io.novafoundation.nova.runtime.ext.legacyAddressOfOrNull
import io.novafoundation.nova.runtime.multiNetwork.chain.model.Chain
import io.novasama.substrate_sdk_android.runtime.AccountId
import kotlin.time.Duration.Companion.seconds
abstract class SubstrateAssetHistory(
private val subqueryApi: SubQueryOperationsApi,
private val cursorStorage: TransferCursorStorage,
private val realtimeOperationFetcherFactory: SubstrateRealtimeOperationFetcher.Factory,
coinPriceRepository: CoinPriceRepository
) : BaseAssetHistory(coinPriceRepository) {
abstract fun realtimeFetcherSources(chain: Chain): List<SubstrateRealtimeOperationFetcher.Factory.Source>
override suspend fun fetchOperationsForBalanceChange(
chain: Chain,
chainAsset: Chain.Asset,
blockHash: String,
accountId: AccountId
): List<RealtimeHistoryUpdate> {
val sources = realtimeFetcherSources(chain)
val realtimeOperationFetcher = realtimeOperationFetcherFactory.create(sources)
return realtimeOperationFetcher.extractRealtimeHistoryUpdates(chain, chainAsset, blockHash)
}
override suspend fun additionalFirstPageSync(
chain: Chain,
chainAsset: Chain.Asset,
accountId: AccountId,
pageResult: Result<DataPage<Operation>>
) {
pageResult
.onSuccess { page ->
val newCursor = page.nextOffset.asCursorOrNull()?.value
cursorStorage.saveCursor(chain.id, chainAsset.id, accountId, newCursor)
}
.onFailure {
// Empty cursor means we haven't yet synced any data for this asset
// However we still want to store null cursor on failure to show items
// that came not from the remote (e.g. local pending operations)
if (!cursorStorage.hasCursor(chain.id, chainAsset.id, accountId)) {
cursorStorage.saveCursor(chain.id, chainAsset.id, accountId, cursor = null)
}
}
}
override suspend fun getOperations(
pageSize: Int,
pageOffset: PageOffset.Loadable,
filters: Set<TransactionFilter>,
accountId: AccountId,
chain: Chain,
chainAsset: Chain.Asset,
currency: Currency,
): DataPage<Operation> {
val substrateTransfersApi = chain.substrateTransfersApi()
return if (substrateTransfersApi != null) {
getOperationsInternal(
pageSize = pageSize,
pageOffset = pageOffset,
filters = filters,
accountId = accountId,
apiUrl = substrateTransfersApi.url,
chainAsset = chainAsset,
chain = chain,
currency = currency
)
} else {
DataPage.empty()
}
}
override suspend fun getSyncedPageOffset(accountId: AccountId, chain: Chain, chainAsset: Chain.Asset): PageOffset {
val substrateTransfersApi = chain.substrateTransfersApi()
return if (substrateTransfersApi != null) {
val cursor = cursorStorage.awaitCursor(chain.id, chainAsset.id, accountId)
PageOffset.CursorOrFull(cursor)
} else {
PageOffset.FullData
}
}
override fun isOperationSafe(operation: Operation): Boolean {
return true
}
private suspend fun getOperationsInternal(
pageSize: Int,
pageOffset: PageOffset.Loadable,
filters: Set<TransactionFilter>,
accountId: AccountId,
chain: Chain,
chainAsset: Chain.Asset,
currency: Currency,
apiUrl: String
): DataPage<Operation> {
val cursor = when (pageOffset) {
is PageOffset.Loadable.Cursor -> pageOffset.value
PageOffset.Loadable.FirstPage -> null
else -> error("SubQuery requires cursor pagination")
}
val request = SubqueryHistoryRequest(
accountAddress = chain.addressOf(accountId),
legacyAccountAddress = chain.legacyAddressOfOrNull(accountId),
pageSize = pageSize,
cursor = cursor,
filters = filters,
asset = chainAsset,
chain = chain
)
val subqueryResponse = subqueryApi.getOperationsHistory(apiUrl, request).data.query
val priceHistory = getPriceHistory(chainAsset, currency)
val assetsBySubQueryId = chain.assetsBySubQueryId()
val operations = subqueryResponse.historyElements.nodes.mapNotNull { node ->
val coinRate = priceHistory.findNearestCoinRate(node.timestamp)
mapNodeToOperation(node, coinRate, chainAsset, assetsBySubQueryId)
}
val pageInfo = subqueryResponse.historyElements.pageInfo
val newPageOffset = PageOffset.CursorOrFull(pageInfo.endCursor)
return DataPage(newPageOffset, operations)
}
private fun Chain.substrateTransfersApi(): Chain.ExternalApi.Transfers.Substrate? {
return externalApi()
}
private fun mapNodeToOperation(
node: SubqueryHistoryElementResponse.Query.HistoryElements.Node,
coinRate: CoinRate?,
chainAsset: Chain.Asset,
chainAssetsBySubQueryId: AssetsBySubQueryId
): Operation? {
val type: Operation.Type
val status: Operation.Status
when {
node.reward != null -> with(node.reward) {
val planks = amount?.toBigIntegerOrNull().orZero()
type = Operation.Type.Reward(
amount = planks,
fiatAmount = coinRate?.convertPlanks(chainAsset, planks),
isReward = isReward,
kind = Operation.Type.Reward.RewardKind.Direct(
era = era,
validator = validator.nullIfEmpty(),
),
eventId = eventId(node.blockNumber, node.reward.eventIdx)
)
status = Operation.Status.COMPLETED
}
node.poolReward != null -> with(node.poolReward) {
type = Operation.Type.Reward(
amount = amount,
fiatAmount = coinRate?.convertPlanks(chainAsset, amount),
isReward = isReward,
kind = Operation.Type.Reward.RewardKind.Pool(
poolId = poolId
),
eventId = eventId(node.blockNumber, node.poolReward.eventIdx)
)
status = Operation.Status.COMPLETED
}
node.extrinsic != null -> with(node.extrinsic) {
type = Operation.Type.Extrinsic(
content = Operation.Type.Extrinsic.Content.SubstrateCall(module, call),
fee = fee,
fiatFee = coinRate?.convertPlanks(chainAsset, fee),
)
status = Operation.Status.fromSuccess(success)
}
node.transfer != null -> with(node.transfer) {
type = Operation.Type.Transfer(
myAddress = node.address,
amount = amount,
fiatAmount = coinRate?.convertPlanks(chainAsset, amount),
receiver = to,
sender = from,
fee = fee,
)
status = Operation.Status.fromSuccess(success)
}
node.assetTransfer != null -> with(node.assetTransfer) {
type = Operation.Type.Transfer(
myAddress = node.address,
amount = amount,
fiatAmount = coinRate?.convertPlanks(chainAsset, amount),
receiver = to,
sender = from,
fee = fee,
)
status = Operation.Status.fromSuccess(success)
}
node.swap != null -> with(node.swap) {
val assetIn = chainAssetsBySubQueryId[assetIdIn] ?: return null
val assetOut = chainAssetsBySubQueryId[assetIdOut] ?: return null
val assetFee = chainAssetsBySubQueryId[assetIdFee] ?: return null
val amount = if (assetIn.fullId == chainAsset.fullId) amountIn else amountOut
type = Operation.Type.Swap(
fee = ChainAssetWithAmount(
chainAsset = assetFee,
amount = fee,
),
amountIn = ChainAssetWithAmount(
chainAsset = assetIn,
amount = amountIn,
),
amountOut = ChainAssetWithAmount(
chainAsset = assetOut,
amount = amountOut,
),
fiatAmount = coinRate?.convertPlanks(chainAsset, amount)
)
status = Operation.Status.fromSuccess(success ?: true)
}
else -> return null
}
return Operation(
id = node.id,
address = node.address,
type = type,
time = node.timestamp.seconds.inWholeMilliseconds,
chainAsset = chainAsset,
extrinsicHash = node.extrinsicHash,
status = status
)
}
private fun eventId(blockNumber: Long, eventIdx: Int): String {
return "$blockNumber-$eventIdx"
}
}
@@ -0,0 +1,51 @@
package io.novafoundation.nova.feature_wallet_impl.data.network.blockchain.assets.history
import io.novafoundation.nova.common.data.model.DataPage
import io.novafoundation.nova.common.data.model.PageOffset
import io.novafoundation.nova.feature_currency_api.domain.model.Currency
import io.novafoundation.nova.feature_wallet_api.data.network.blockhain.assets.history.AssetHistory
import io.novafoundation.nova.feature_wallet_api.data.network.blockhain.assets.history.realtime.RealtimeHistoryUpdate
import io.novafoundation.nova.feature_wallet_api.domain.interfaces.TransactionFilter
import io.novafoundation.nova.feature_wallet_api.domain.model.Operation
import io.novafoundation.nova.runtime.multiNetwork.chain.model.Chain
import io.novasama.substrate_sdk_android.runtime.AccountId
class UnsupportedAssetHistory : AssetHistory {
override suspend fun fetchOperationsForBalanceChange(
chain: Chain,
chainAsset: Chain.Asset,
blockHash: String,
accountId: AccountId
): List<RealtimeHistoryUpdate> {
return emptyList()
}
override fun availableOperationFilters(chain: Chain, asset: Chain.Asset): Set<TransactionFilter> {
return emptySet()
}
override suspend fun additionalFirstPageSync(chain: Chain, chainAsset: Chain.Asset, accountId: AccountId, page: Result<DataPage<Operation>>) {
// do nothing
}
override suspend fun getOperations(
pageSize: Int,
pageOffset: PageOffset.Loadable,
filters: Set<TransactionFilter>,
accountId: AccountId,
chain: Chain,
chainAsset: Chain.Asset,
currency: Currency
): DataPage<Operation> {
return DataPage.empty()
}
override suspend fun getSyncedPageOffset(accountId: AccountId, chain: Chain, chainAsset: Chain.Asset): PageOffset {
return PageOffset.FullData
}
override fun isOperationSafe(operation: Operation): Boolean {
return false
}
}
@@ -0,0 +1,72 @@
package io.novafoundation.nova.feature_wallet_impl.data.network.blockchain.assets.history.equilibrium
import io.novafoundation.nova.common.data.network.runtime.binding.bindAccountIdentifier
import io.novafoundation.nova.common.data.network.runtime.binding.bindNumber
import io.novafoundation.nova.common.utils.eqBalances
import io.novafoundation.nova.common.utils.instanceOf
import io.novafoundation.nova.feature_wallet_api.data.network.blockhain.assets.history.realtime.RealtimeHistoryUpdate
import io.novafoundation.nova.feature_wallet_api.data.network.blockhain.assets.history.realtime.substrate.SubstrateRealtimeOperationFetcher
import io.novafoundation.nova.feature_wallet_api.data.network.blockhain.assets.history.realtime.substrate.SubstrateRealtimeOperationFetcher.Factory.Source
import io.novafoundation.nova.feature_wallet_api.data.network.blockhain.assets.history.realtime.substrate.asSource
import io.novafoundation.nova.feature_wallet_api.data.repository.CoinPriceRepository
import io.novafoundation.nova.feature_wallet_api.domain.interfaces.TransactionFilter
import io.novafoundation.nova.feature_wallet_impl.data.network.blockchain.assets.history.SubstrateAssetHistory
import io.novafoundation.nova.feature_wallet_impl.data.network.subquery.SubQueryOperationsApi
import io.novafoundation.nova.feature_wallet_impl.data.storage.TransferCursorStorage
import io.novafoundation.nova.runtime.ext.isUtilityAsset
import io.novafoundation.nova.runtime.extrinsic.visitor.extrinsic.api.ExtrinsicVisit
import io.novafoundation.nova.runtime.multiNetwork.ChainRegistry
import io.novafoundation.nova.runtime.multiNetwork.chain.model.Chain
import io.novafoundation.nova.runtime.multiNetwork.getRuntime
import io.novasama.substrate_sdk_android.runtime.RuntimeSnapshot
import io.novasama.substrate_sdk_android.runtime.definitions.types.generics.GenericCall
import io.novasama.substrate_sdk_android.runtime.metadata.call
class EquilibriumAssetHistory(
private val chainRegistry: ChainRegistry,
walletOperationsApi: SubQueryOperationsApi,
cursorStorage: TransferCursorStorage,
coinPriceRepository: CoinPriceRepository,
realtimeOperationFetcherFactory: SubstrateRealtimeOperationFetcher.Factory
) : SubstrateAssetHistory(walletOperationsApi, cursorStorage, realtimeOperationFetcherFactory, coinPriceRepository) {
override fun realtimeFetcherSources(chain: Chain): List<Source> {
return listOf(TransferExtractor().asSource())
}
override fun availableOperationFilters(chain: Chain, asset: Chain.Asset): Set<TransactionFilter> {
return setOfNotNull(
TransactionFilter.TRANSFER,
TransactionFilter.EXTRINSIC.takeIf { asset.isUtilityAsset }
)
}
private inner class TransferExtractor : SubstrateRealtimeOperationFetcher.Extractor {
override suspend fun extractRealtimeHistoryUpdates(
extrinsicVisit: ExtrinsicVisit,
chain: Chain,
chainAsset: Chain.Asset
): RealtimeHistoryUpdate.Type? {
val runtime = chainRegistry.getRuntime(chain.id)
val call = extrinsicVisit.call
if (!call.isTransfer(runtime)) return null
val amount = bindNumber(call.arguments["value"])
return RealtimeHistoryUpdate.Type.Transfer(
senderId = extrinsicVisit.origin,
recipientId = bindAccountIdentifier(call.arguments["to"]),
amountInPlanks = amount,
chainAsset = chainAsset,
)
}
private fun GenericCall.Instance.isTransfer(runtime: RuntimeSnapshot): Boolean {
val balances = runtime.metadata.eqBalances()
return instanceOf(balances.call("transfer"))
}
}
}
@@ -0,0 +1,97 @@
package io.novafoundation.nova.feature_wallet_impl.data.network.blockchain.assets.history.evmErc20
import io.novafoundation.nova.feature_currency_api.domain.model.Currency
import io.novafoundation.nova.feature_wallet_api.data.network.blockhain.assets.history.realtime.RealtimeHistoryUpdate
import io.novafoundation.nova.feature_wallet_api.data.repository.CoinPriceRepository
import io.novafoundation.nova.feature_wallet_api.domain.interfaces.TransactionFilter
import io.novafoundation.nova.feature_wallet_api.domain.model.CoinRate
import io.novafoundation.nova.feature_wallet_api.domain.model.Operation
import io.novafoundation.nova.feature_wallet_api.domain.model.convertPlanks
import io.novafoundation.nova.feature_wallet_api.domain.model.findNearestCoinRate
import io.novafoundation.nova.feature_wallet_api.domain.model.isZeroTransfer
import io.novafoundation.nova.feature_wallet_impl.data.network.blockchain.assets.history.EvmAssetHistory
import io.novafoundation.nova.feature_wallet_impl.data.network.etherscan.EtherscanTransactionsApi
import io.novafoundation.nova.feature_wallet_impl.data.network.etherscan.model.EtherscanAccountTransfer
import io.novafoundation.nova.feature_wallet_impl.data.network.etherscan.model.feeUsed
import io.novafoundation.nova.runtime.ext.addressOf
import io.novafoundation.nova.runtime.ext.requireErc20
import io.novafoundation.nova.runtime.multiNetwork.chain.model.Chain
import io.novasama.substrate_sdk_android.runtime.AccountId
import kotlin.time.Duration.Companion.seconds
class EvmErc20AssetHistory(
private val etherscanTransactionsApi: EtherscanTransactionsApi,
coinPriceRepository: CoinPriceRepository
) : EvmAssetHistory(coinPriceRepository) {
override suspend fun fetchEtherscanOperations(
chain: Chain,
chainAsset: Chain.Asset,
accountId: AccountId,
apiUrl: String,
page: Int,
pageSize: Int,
currency: Currency
): List<Operation> {
val erc20Config = chainAsset.requireErc20()
val accountAddress = chain.addressOf(accountId)
val response = etherscanTransactionsApi.getErc20Transfers(
baseUrl = apiUrl,
contractAddress = erc20Config.contractAddress,
accountAddress = accountAddress,
pageNumber = page,
pageSize = pageSize,
chainId = chain.id
)
val priceHistory = getPriceHistory(chainAsset, currency)
return response.result.map {
val coinRate = priceHistory.findNearestCoinRate(it.timeStamp)
mapRemoteTransferToOperation(it, chainAsset, accountAddress, coinRate)
}
}
override suspend fun fetchOperationsForBalanceChange(
chain: Chain,
chainAsset: Chain.Asset,
blockHash: String,
accountId: AccountId,
): List<RealtimeHistoryUpdate> {
// we fetch transfers alongside with balance updates in EvmAssetBalance
return emptyList()
}
override fun availableOperationFilters(chain: Chain, asset: Chain.Asset): Set<TransactionFilter> {
return setOf(TransactionFilter.TRANSFER)
}
override fun isOperationSafe(operation: Operation): Boolean {
return !operation.isZeroTransfer()
}
private fun mapRemoteTransferToOperation(
remote: EtherscanAccountTransfer,
chainAsset: Chain.Asset,
accountAddress: String,
coinRate: CoinRate?
): Operation {
return Operation(
id = remote.hash,
address = accountAddress,
extrinsicHash = remote.hash,
type = Operation.Type.Transfer(
myAddress = accountAddress,
amount = remote.value,
fiatAmount = coinRate?.convertPlanks(chainAsset, remote.value),
receiver = remote.to,
sender = remote.from,
fee = remote.feeUsed
),
time = remote.timeStamp.seconds.inWholeMilliseconds,
chainAsset = chainAsset,
status = Operation.Status.COMPLETED,
)
}
}
@@ -0,0 +1,189 @@
package io.novafoundation.nova.feature_wallet_impl.data.network.blockchain.assets.history.evmNative
import io.novafoundation.nova.common.utils.ethereumAddressToAccountId
import io.novafoundation.nova.common.utils.removeHexPrefix
import io.novafoundation.nova.feature_currency_api.domain.model.Currency
import io.novafoundation.nova.feature_wallet_api.data.network.blockhain.assets.history.realtime.RealtimeHistoryUpdate
import io.novafoundation.nova.feature_wallet_api.data.repository.CoinPriceRepository
import io.novafoundation.nova.feature_wallet_api.domain.interfaces.TransactionFilter
import io.novafoundation.nova.feature_wallet_api.domain.model.CoinRate
import io.novafoundation.nova.feature_wallet_api.domain.model.Operation
import io.novafoundation.nova.feature_wallet_api.domain.model.Operation.Type.Extrinsic.Content
import io.novafoundation.nova.feature_wallet_api.domain.model.convertPlanks
import io.novafoundation.nova.feature_wallet_api.domain.model.findNearestCoinRate
import io.novafoundation.nova.feature_wallet_impl.data.network.blockchain.assets.history.EvmAssetHistory
import io.novafoundation.nova.feature_wallet_impl.data.network.etherscan.EtherscanTransactionsApi
import io.novafoundation.nova.feature_wallet_impl.data.network.etherscan.model.EtherscanNormalTxResponse
import io.novafoundation.nova.feature_wallet_impl.data.network.etherscan.model.feeUsed
import io.novafoundation.nova.feature_wallet_impl.data.network.etherscan.model.isTransfer
import io.novafoundation.nova.runtime.ethereum.sendSuspend
import io.novafoundation.nova.runtime.ext.accountIdOf
import io.novafoundation.nova.runtime.ext.addressOf
import io.novafoundation.nova.runtime.multiNetwork.ChainRegistry
import io.novafoundation.nova.runtime.multiNetwork.getCallEthereumApiOrThrow
import io.novafoundation.nova.runtime.multiNetwork.chain.model.Chain
import io.novasama.substrate_sdk_android.runtime.AccountId
import org.web3j.protocol.core.methods.response.EthBlock
import org.web3j.protocol.core.methods.response.EthBlock.TransactionResult
import org.web3j.protocol.core.methods.response.Transaction
import org.web3j.protocol.core.methods.response.TransactionReceipt
import java.math.BigInteger
import kotlin.jvm.optionals.getOrNull
import kotlin.time.Duration.Companion.seconds
class EvmNativeAssetHistory(
private val chainRegistry: ChainRegistry,
private val etherscanTransactionsApi: EtherscanTransactionsApi,
coinPriceRepository: CoinPriceRepository
) : EvmAssetHistory(coinPriceRepository) {
override suspend fun fetchEtherscanOperations(
chain: Chain,
chainAsset: Chain.Asset,
accountId: AccountId,
apiUrl: String,
page: Int,
pageSize: Int,
currency: Currency
): List<Operation> {
val accountAddress = chain.addressOf(accountId)
val response = etherscanTransactionsApi.getNormalTxsHistory(
baseUrl = apiUrl,
accountAddress = accountAddress,
pageNumber = page,
pageSize = pageSize,
chainId = chain.id
)
val priceHistory = getPriceHistory(chainAsset, currency)
return response.result
.map {
val coinRate = priceHistory.findNearestCoinRate(it.timeStamp)
mapRemoteNormalTxToOperation(it, chainAsset, accountAddress, coinRate)
}
}
@OptIn(ExperimentalStdlibApi::class)
@Suppress("UNCHECKED_CAST")
override suspend fun fetchOperationsForBalanceChange(
chain: Chain,
chainAsset: Chain.Asset,
blockHash: String,
accountId: AccountId,
): List<RealtimeHistoryUpdate> {
val ethereumApi = chainRegistry.getCallEthereumApiOrThrow(chain.id)
val block = ethereumApi.ethGetBlockByHash(blockHash, true).sendSuspend()
val txs = block.block.transactions as List<TransactionResult<EthBlock.TransactionObject>>
return txs.mapNotNull {
val tx = it.get()
val isTransfer = tx.input.removeHexPrefix().isEmpty()
val relatesToUs = tx.relatesTo(accountId)
if (!(isTransfer && relatesToUs)) return@mapNotNull null
val txReceipt = ethereumApi.ethGetTransactionReceipt(tx.hash).sendSuspend().transactionReceipt.getOrNull()
RealtimeHistoryUpdate(
status = txReceipt.extrinsicStatus(),
txHash = tx.hash,
type = RealtimeHistoryUpdate.Type.Transfer(
senderId = chain.accountIdOf(tx.from),
recipientId = chain.accountIdOf(tx.to),
amountInPlanks = tx.value,
chainAsset = chainAsset,
)
)
}
}
override fun availableOperationFilters(chain: Chain, asset: Chain.Asset): Set<TransactionFilter> {
return setOf(TransactionFilter.TRANSFER, TransactionFilter.EXTRINSIC)
}
override fun isOperationSafe(operation: Operation): Boolean {
return true
}
private fun TransactionReceipt?.extrinsicStatus(): Operation.Status {
return when (this?.isStatusOK) {
true -> Operation.Status.COMPLETED
false -> Operation.Status.FAILED
null -> Operation.Status.PENDING
}
}
private fun Transaction.relatesTo(accountId: AccountId): Boolean {
return from.ethAccountIdMatches(accountId) || to.ethAccountIdMatches(accountId)
}
private fun String?.ethAccountIdMatches(other: AccountId): Boolean {
return other.contentEquals(this?.ethereumAddressToAccountId())
}
private fun mapRemoteNormalTxToOperation(
remote: EtherscanNormalTxResponse,
chainAsset: Chain.Asset,
accountAddress: String,
coinRate: CoinRate?
): Operation {
val type = if (remote.isTransfer) {
mapNativeTransferToTransfer(remote, accountAddress, chainAsset, coinRate)
} else {
mapContractCallToExtrinsic(remote, chainAsset, coinRate)
}
return Operation(
id = remote.hash,
address = accountAddress,
type = type,
time = remote.timeStamp.seconds.inWholeMilliseconds,
chainAsset = chainAsset,
extrinsicHash = remote.hash,
status = remote.operationStatus(),
)
}
private fun mapNativeTransferToTransfer(
remote: EtherscanNormalTxResponse,
accountAddress: String,
chainAsset: Chain.Asset,
coinRate: CoinRate?
): Operation.Type.Transfer {
return Operation.Type.Transfer(
myAddress = accountAddress,
amount = remote.value,
fiatAmount = coinRate?.convertPlanks(chainAsset, remote.value),
receiver = remote.to,
sender = remote.from,
fee = remote.feeUsed
)
}
private fun mapContractCallToExtrinsic(
remote: EtherscanNormalTxResponse,
chainAsset: Chain.Asset,
coinRate: CoinRate?
): Operation.Type.Extrinsic {
return Operation.Type.Extrinsic(
content = Content.ContractCall(
contractAddress = remote.to,
function = remote.functionName,
),
fee = remote.feeUsed,
fiatFee = coinRate?.convertPlanks(chainAsset, remote.feeUsed),
)
}
private fun EtherscanNormalTxResponse.operationStatus(): Operation.Status {
return if (txReceiptStatus == BigInteger.ONE) {
Operation.Status.COMPLETED
} else {
Operation.Status.FAILED
}
}
}
@@ -0,0 +1,86 @@
package io.novafoundation.nova.feature_wallet_impl.data.network.blockchain.assets.history.orml
import io.novafoundation.nova.common.data.network.runtime.binding.bindAccountIdentifier
import io.novafoundation.nova.common.data.network.runtime.binding.bindNumber
import io.novafoundation.nova.common.utils.currenciesOrNull
import io.novafoundation.nova.common.utils.instanceOf
import io.novafoundation.nova.common.utils.tokens
import io.novafoundation.nova.feature_wallet_api.data.network.blockhain.assets.history.realtime.RealtimeHistoryUpdate
import io.novafoundation.nova.feature_wallet_api.data.network.blockhain.assets.history.realtime.substrate.SubstrateRealtimeOperationFetcher
import io.novafoundation.nova.feature_wallet_api.data.network.blockhain.assets.history.realtime.substrate.SubstrateRealtimeOperationFetcher.Factory.Source
import io.novafoundation.nova.feature_wallet_api.data.network.blockhain.assets.history.realtime.substrate.asSource
import io.novafoundation.nova.feature_wallet_api.data.repository.CoinPriceRepository
import io.novafoundation.nova.feature_wallet_api.domain.interfaces.TransactionFilter
import io.novafoundation.nova.feature_wallet_impl.data.network.blockchain.assets.history.SubstrateAssetHistory
import io.novafoundation.nova.feature_wallet_impl.data.network.subquery.SubQueryOperationsApi
import io.novafoundation.nova.feature_wallet_impl.data.storage.TransferCursorStorage
import io.novafoundation.nova.runtime.ext.findAssetByOrmlCurrencyId
import io.novafoundation.nova.runtime.ext.hydraDxSupported
import io.novafoundation.nova.runtime.ext.isSwapSupported
import io.novafoundation.nova.runtime.ext.isUtilityAsset
import io.novafoundation.nova.runtime.extrinsic.visitor.extrinsic.api.ExtrinsicVisit
import io.novafoundation.nova.runtime.multiNetwork.ChainRegistry
import io.novafoundation.nova.runtime.multiNetwork.chain.model.Chain
import io.novafoundation.nova.runtime.multiNetwork.getRuntime
import io.novasama.substrate_sdk_android.runtime.RuntimeSnapshot
import io.novasama.substrate_sdk_android.runtime.definitions.types.generics.GenericCall
import io.novasama.substrate_sdk_android.runtime.metadata.call
import io.novasama.substrate_sdk_android.runtime.metadata.callOrNull
class OrmlAssetHistory(
private val chainRegistry: ChainRegistry,
realtimeOperationFetcherFactory: SubstrateRealtimeOperationFetcher.Factory,
walletOperationsApi: SubQueryOperationsApi,
cursorStorage: TransferCursorStorage,
coinPriceRepository: CoinPriceRepository
) : SubstrateAssetHistory(walletOperationsApi, cursorStorage, realtimeOperationFetcherFactory, coinPriceRepository) {
override fun realtimeFetcherSources(chain: Chain): List<Source> {
return buildList {
add(TransferExtractor().asSource())
if (chain.swap.hydraDxSupported()) {
add(Source.Known.Id.HYDRA_DX_SWAP.asSource())
}
}
}
override fun availableOperationFilters(chain: Chain, asset: Chain.Asset): Set<TransactionFilter> {
return setOfNotNull(
TransactionFilter.TRANSFER,
TransactionFilter.EXTRINSIC.takeIf { asset.isUtilityAsset },
TransactionFilter.SWAP.takeIf { chain.isSwapSupported() }
)
}
private inner class TransferExtractor : SubstrateRealtimeOperationFetcher.Extractor {
override suspend fun extractRealtimeHistoryUpdates(
extrinsicVisit: ExtrinsicVisit,
chain: Chain,
chainAsset: Chain.Asset
): RealtimeHistoryUpdate.Type? {
val runtime = chainRegistry.getRuntime(chain.id)
val call = extrinsicVisit.call
if (!call.isTransfer(runtime)) return null
val inferredAsset = chain.findAssetByOrmlCurrencyId(runtime, call.arguments["currency_id"]) ?: return null
val amount = bindNumber(call.arguments["amount"])
return RealtimeHistoryUpdate.Type.Transfer(
senderId = extrinsicVisit.origin,
recipientId = bindAccountIdentifier(call.arguments["dest"]),
amountInPlanks = amount,
chainAsset = inferredAsset,
)
}
private fun GenericCall.Instance.isTransfer(runtime: RuntimeSnapshot): Boolean {
val transferCall = runtime.metadata.currenciesOrNull()?.callOrNull("transfer")
?: runtime.metadata.tokens().call("transfer")
return instanceOf(transferCall)
}
}
}
@@ -0,0 +1,129 @@
package io.novafoundation.nova.feature_wallet_impl.data.network.blockchain.assets.history.realtime.substrate
import io.novafoundation.nova.feature_xcm_api.multiLocation.bindMultiLocation
import io.novafoundation.nova.common.data.network.runtime.binding.bindAccountId
import io.novafoundation.nova.common.data.network.runtime.binding.bindList
import io.novafoundation.nova.common.data.network.runtime.binding.bindNumber
import io.novafoundation.nova.common.utils.Modules
import io.novafoundation.nova.feature_wallet_api.data.network.blockhain.assets.history.realtime.RealtimeHistoryUpdate
import io.novafoundation.nova.feature_wallet_api.data.network.blockhain.assets.history.realtime.substrate.SubstrateRealtimeOperationFetcher
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_xcm_api.converter.MultiLocationConverter
import io.novafoundation.nova.feature_xcm_api.converter.MultiLocationConverterFactory
import io.novafoundation.nova.runtime.ext.commissionAsset
import io.novafoundation.nova.runtime.extrinsic.visitor.extrinsic.api.ExtrinsicVisit
import io.novafoundation.nova.runtime.multiNetwork.chain.model.Chain
import io.novafoundation.nova.runtime.multiNetwork.runtime.repository.assetTxFeePaidEvent
import io.novafoundation.nova.runtime.multiNetwork.runtime.repository.findEvents
import io.novafoundation.nova.runtime.multiNetwork.runtime.repository.requireNativeFee
import io.novasama.substrate_sdk_android.runtime.definitions.types.generics.GenericCall
import kotlinx.coroutines.CoroutineScope
import kotlin.coroutines.coroutineContext
class AssetConversionSwapExtractor(
private val multiLocationConverterFactory: MultiLocationConverterFactory,
) : SubstrateRealtimeOperationFetcher.Extractor {
private val calls = listOf("swap_exact_tokens_for_tokens", "swap_tokens_for_exact_tokens")
override suspend fun extractRealtimeHistoryUpdates(
extrinsicVisit: ExtrinsicVisit,
chain: Chain,
chainAsset: Chain.Asset
): RealtimeHistoryUpdate.Type? {
val call = extrinsicVisit.call
val callArgs = call.arguments
if (!call.isSwap()) return null
val scope = CoroutineScope(coroutineContext)
val multiLocationConverter = multiLocationConverterFactory.defaultAsync(chain, scope)
val path = bindList(callArgs["path"], ::bindMultiLocation)
val assetIn = multiLocationConverter.toChainAsset(path.first()) ?: return null
val assetOut = multiLocationConverter.toChainAsset(path.last()) ?: return null
val (amountIn, amountOut) = extrinsicVisit.extractSwapAmounts()
val sendTo = bindAccountId(callArgs["send_to"])
val fee = extrinsicVisit.extractFee(chain, multiLocationConverter)
return RealtimeHistoryUpdate.Type.Swap(
amountIn = ChainAssetWithAmount(assetIn, amountIn),
amountOut = ChainAssetWithAmount(assetOut, amountOut),
amountFee = fee,
senderId = extrinsicVisit.origin,
receiverId = sendTo
)
}
private fun ExtrinsicVisit.extractSwapAmounts(): Pair<Balance, Balance> {
// We check for custom fee usage from root extrinsic since `extrinsicVisit` will cut it out when nested calls are present
val isCustomFeeTokenUsed = rootExtrinsic.events.assetTxFeePaidEvent() != null
val allSwaps = events.findEvents(Modules.ASSET_CONVERSION, "SwapExecuted")
val swapExecutedEvent = when {
!success -> null // we wont be able to extract swap from event
isCustomFeeTokenUsed -> {
// Swaps with custom fee token produce up to free SwapExecuted events, in the following order:
// SwapExecuted (Swap custom token fee to native token) - always present
// SwapExecuted (Real swap) - always present
// SwapExecuted (Refund remaining fee back to custom token)
// So we need to take the middle one
allSwaps.getOrNull(1)
}
else -> {
// Only one swap is possible in case
allSwaps.firstOrNull()
}
}
return when {
// successful swap, extract from event
swapExecutedEvent != null -> {
val (_, _, amountIn, amountOut) = swapExecutedEvent.arguments
bindNumber(amountIn) to bindNumber(amountOut)
}
// failed swap, extract from call args
call.function.name == "swap_exact_tokens_for_tokens" -> {
val amountIn = bindNumber(call.arguments["amount_in"])
val amountOutMin = bindNumber(call.arguments["amount_out_min"])
amountIn to amountOutMin
}
call.function.name == "swap_tokens_for_exact_tokens" -> {
val amountOut = bindNumber(call.arguments["amount_out"])
val amountInMax = bindNumber(call.arguments["amount_in_max"])
amountInMax to amountOut
}
else -> error("Unknown call")
}
}
private suspend fun ExtrinsicVisit.extractFee(
chain: Chain,
multiLocationConverter: MultiLocationConverter
): ChainAssetWithAmount {
// We check for fee usage from root extrinsic since `extrinsicVisit` will cut it out when nested calls are present
val assetFee = rootExtrinsic.events.assetFee(multiLocationConverter)
if (assetFee != null) return assetFee
val nativeFee = rootExtrinsic.events.requireNativeFee()
return ChainAssetWithAmount(chain.commissionAsset, nativeFee)
}
private fun GenericCall.Instance.isSwap(): Boolean {
return module.name == Modules.ASSET_CONVERSION &&
function.name in calls
}
}
@@ -0,0 +1,17 @@
package io.novafoundation.nova.feature_wallet_impl.data.network.blockchain.assets.history.realtime.substrate
import io.novafoundation.nova.feature_xcm_api.multiLocation.bindMultiLocation
import io.novafoundation.nova.common.data.network.runtime.binding.bindNumber
import io.novafoundation.nova.feature_wallet_api.domain.model.ChainAssetWithAmount
import io.novafoundation.nova.feature_xcm_api.converter.MultiLocationConverter
import io.novafoundation.nova.runtime.multiNetwork.runtime.repository.assetTxFeePaidEvent
import io.novasama.substrate_sdk_android.runtime.definitions.types.generics.GenericEvent
suspend fun List<GenericEvent.Instance>.assetFee(multiLocationConverter: MultiLocationConverter): ChainAssetWithAmount? {
val event = assetTxFeePaidEvent() ?: return null
val (_, actualFee, tip, assetId) = event.arguments
val totalFee = bindNumber(actualFee) + bindNumber(tip)
val chainAsset = multiLocationConverter.toChainAsset(bindMultiLocation(assetId)) ?: return null
return ChainAssetWithAmount(chainAsset, totalFee)
}
@@ -0,0 +1,86 @@
package io.novafoundation.nova.feature_wallet_impl.data.network.blockchain.assets.history.realtime.substrate
import io.novafoundation.nova.feature_swap_core_api.data.network.HydraDxAssetIdConverter
import io.novafoundation.nova.feature_wallet_api.data.network.blockhain.assets.history.realtime.RealtimeHistoryUpdate
import io.novafoundation.nova.feature_wallet_api.data.network.blockhain.assets.history.realtime.substrate.SubstrateRealtimeOperationFetcher
import io.novafoundation.nova.feature_wallet_api.data.network.blockhain.assets.history.realtime.substrate.SubstrateRealtimeOperationFetcher.Extractor
import io.novafoundation.nova.feature_wallet_api.data.network.blockhain.assets.history.realtime.substrate.SubstrateRealtimeOperationFetcher.Factory
import io.novafoundation.nova.feature_wallet_api.domain.model.Operation
import io.novafoundation.nova.feature_wallet_impl.data.network.blockchain.assets.history.realtime.substrate.hydraDx.HydraDxOmniPoolSwapExtractor
import io.novafoundation.nova.feature_wallet_impl.data.network.blockchain.assets.history.realtime.substrate.hydraDx.HydraDxRouterSwapExtractor
import io.novafoundation.nova.runtime.extrinsic.visitor.extrinsic.api.ExtrinsicWalk
import io.novafoundation.nova.runtime.extrinsic.visitor.extrinsic.api.walkToList
import io.novafoundation.nova.runtime.multiNetwork.chain.model.Chain
import io.novafoundation.nova.feature_xcm_api.converter.MultiLocationConverterFactory
import io.novafoundation.nova.runtime.multiNetwork.runtime.repository.EventsRepository
internal class SubstrateRealtimeOperationFetcherFactory(
private val multiLocationConverterFactory: MultiLocationConverterFactory,
private val hydraDxAssetIdConverter: HydraDxAssetIdConverter,
private val eventsRepository: EventsRepository,
private val extrinsicWalk: ExtrinsicWalk,
) : Factory {
override fun create(sources: List<Factory.Source>): SubstrateRealtimeOperationFetcher {
val extractors = sources.flatMap { it.extractors() }
return RealSubstrateRealtimeOperationFetcher(eventsRepository, extractors, extrinsicWalk)
}
private fun Factory.Source.extractors(): List<Extractor> {
return when (this) {
is Factory.Source.FromExtractor -> listOf(extractor)
is Factory.Source.Known -> id.extractors()
}
}
private fun Factory.Source.Known.Id.extractors(): List<Extractor> {
return when (this) {
Factory.Source.Known.Id.ASSET_CONVERSION_SWAP -> listOf(assetConversionSwap())
Factory.Source.Known.Id.HYDRA_DX_SWAP -> listOf(hydraDxOmniPoolSwap(), hydraDxRouterSwap())
}
}
private fun assetConversionSwap(): Extractor {
return AssetConversionSwapExtractor(multiLocationConverterFactory)
}
private fun hydraDxOmniPoolSwap(): Extractor {
return HydraDxOmniPoolSwapExtractor(hydraDxAssetIdConverter)
}
private fun hydraDxRouterSwap(): Extractor {
return HydraDxRouterSwapExtractor(hydraDxAssetIdConverter)
}
}
private class RealSubstrateRealtimeOperationFetcher(
private val repository: EventsRepository,
private val extractors: List<Extractor>,
private val callWalk: ExtrinsicWalk,
) : SubstrateRealtimeOperationFetcher {
override suspend fun extractRealtimeHistoryUpdates(
chain: Chain,
chainAsset: Chain.Asset,
blockHash: String
): List<RealtimeHistoryUpdate> {
val extrinsicWithEvents = repository.getBlockEvents(chain.id, blockHash).applyExtrinsic
return extrinsicWithEvents.flatMap { extrinsic ->
val visits = runCatching { callWalk.walkToList(extrinsic, chain.id) }.getOrElse { emptyList() }
visits.flatMap { extrinsicVisit ->
extractors.mapNotNull {
val type = runCatching { it.extractRealtimeHistoryUpdates(extrinsicVisit, chain, chainAsset) }.getOrNull() ?: return@mapNotNull null
RealtimeHistoryUpdate(
txHash = extrinsic.extrinsicHash,
status = Operation.Status.fromSuccess(extrinsicVisit.success),
type = type
)
}
}
}
}
}
@@ -0,0 +1,69 @@
package io.novafoundation.nova.feature_wallet_impl.data.network.blockchain.assets.history.realtime.substrate.hydraDx
import io.novafoundation.nova.common.data.network.runtime.binding.bindNumber
import io.novafoundation.nova.common.utils.Modules
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_wallet_api.data.network.blockhain.assets.history.realtime.RealtimeHistoryUpdate
import io.novafoundation.nova.feature_wallet_api.data.network.blockhain.assets.history.realtime.substrate.SubstrateRealtimeOperationFetcher
import io.novafoundation.nova.feature_wallet_api.domain.model.ChainAssetWithAmount
import io.novafoundation.nova.runtime.ext.utilityAsset
import io.novafoundation.nova.runtime.extrinsic.visitor.extrinsic.api.ExtrinsicVisit
import io.novafoundation.nova.runtime.multiNetwork.chain.model.Chain
import io.novafoundation.nova.runtime.multiNetwork.runtime.repository.findLastEvent
import io.novafoundation.nova.runtime.multiNetwork.runtime.repository.requireNativeFee
import io.novasama.substrate_sdk_android.runtime.definitions.types.generics.GenericCall
abstract class BaseHydraDxSwapExtractor(
private val hydraDxAssetIdConverter: HydraDxAssetIdConverter,
) : SubstrateRealtimeOperationFetcher.Extractor {
abstract fun isSwap(call: GenericCall.Instance): Boolean
protected abstract fun ExtrinsicVisit.extractSwapArgs(): SwapArgs
override suspend fun extractRealtimeHistoryUpdates(
extrinsicVisit: ExtrinsicVisit,
chain: Chain,
chainAsset: Chain.Asset
): RealtimeHistoryUpdate.Type? {
if (!isSwap(extrinsicVisit.call)) return null
val (assetIdIn, assetIdOut, amountIn, amountOut) = extrinsicVisit.extractSwapArgs()
val assetIn = hydraDxAssetIdConverter.toChainAssetOrNull(chain, assetIdIn) ?: return null
val assetOut = hydraDxAssetIdConverter.toChainAssetOrNull(chain, assetIdOut) ?: return null
val fee = extrinsicVisit.extractFee(chain)
return RealtimeHistoryUpdate.Type.Swap(
amountIn = ChainAssetWithAmount(assetIn, amountIn),
amountOut = ChainAssetWithAmount(assetOut, amountOut),
amountFee = fee,
senderId = extrinsicVisit.origin,
receiverId = extrinsicVisit.origin
)
}
private suspend fun ExtrinsicVisit.extractFee(chain: Chain): ChainAssetWithAmount {
val feeDepositEvent = rootExtrinsic.events.findLastEvent(Modules.CURRENCIES, "Deposited") ?: return nativeFee(chain)
val (currencyIdRaw, _, amountRaw) = feeDepositEvent.arguments
val currencyId = bindNumber(currencyIdRaw)
val feeAsset = hydraDxAssetIdConverter.toChainAssetOrNull(chain, currencyId) ?: return nativeFee(chain)
return ChainAssetWithAmount(feeAsset, bindNumber(amountRaw))
}
private fun ExtrinsicVisit.nativeFee(chain: Chain): ChainAssetWithAmount {
return ChainAssetWithAmount(chain.utilityAsset, rootExtrinsic.events.requireNativeFee())
}
protected data class SwapArgs(
val assetIn: HydraDxAssetId,
val assetOut: HydraDxAssetId,
val amountIn: HydraDxAssetId,
val amountOut: HydraDxAssetId
)
}
@@ -0,0 +1,60 @@
package io.novafoundation.nova.feature_wallet_impl.data.network.blockchain.assets.history.realtime.substrate.hydraDx
import io.novafoundation.nova.common.data.network.runtime.binding.bindNumber
import io.novafoundation.nova.common.utils.Modules
import io.novafoundation.nova.feature_swap_core_api.data.network.HydraDxAssetIdConverter
import io.novafoundation.nova.runtime.extrinsic.visitor.extrinsic.api.ExtrinsicVisit
import io.novafoundation.nova.runtime.multiNetwork.runtime.repository.findEvent
import io.novasama.substrate_sdk_android.runtime.definitions.types.generics.GenericCall
class HydraDxOmniPoolSwapExtractor(
hydraDxAssetIdConverter: HydraDxAssetIdConverter,
) : BaseHydraDxSwapExtractor(hydraDxAssetIdConverter) {
private val calls = listOf("buy", "sell")
override fun isSwap(call: GenericCall.Instance): Boolean {
return call.module.name == Modules.OMNIPOOL &&
call.function.name in calls
}
override fun ExtrinsicVisit.extractSwapArgs(): SwapArgs {
val swapExecutedEvent = events.findEvent(Modules.OMNIPOOL, "BuyExecuted")
?: events.findEvent(Modules.OMNIPOOL, "SellExecuted")
return when {
// successful swap, extract from event
swapExecutedEvent != null -> {
val (_, assetIn, assetOut, amountIn, amountOut) = swapExecutedEvent.arguments
SwapArgs(
assetIn = bindNumber(assetIn),
assetOut = bindNumber(assetOut),
amountIn = bindNumber(amountIn),
amountOut = bindNumber(amountOut)
)
}
// failed swap, extract from call args
call.function.name == "sell" -> {
SwapArgs(
assetIn = bindNumber(call.arguments["asset_in"]),
assetOut = bindNumber(call.arguments["asset_out"]),
amountIn = bindNumber(call.arguments["amount"]),
amountOut = bindNumber(call.arguments["min_buy_amount"])
)
}
call.function.name == "buy" -> {
SwapArgs(
assetIn = bindNumber(call.arguments["asset_in"]),
assetOut = bindNumber(call.arguments["asset_out"]),
amountIn = bindNumber(call.arguments["max_sell_amount"]),
amountOut = bindNumber(call.arguments["amount"])
)
}
else -> error("Unknown call")
}
}
}
@@ -0,0 +1,60 @@
package io.novafoundation.nova.feature_wallet_impl.data.network.blockchain.assets.history.realtime.substrate.hydraDx
import io.novafoundation.nova.common.data.network.runtime.binding.bindNumber
import io.novafoundation.nova.common.utils.Modules
import io.novafoundation.nova.feature_swap_core_api.data.network.HydraDxAssetIdConverter
import io.novafoundation.nova.runtime.extrinsic.visitor.extrinsic.api.ExtrinsicVisit
import io.novafoundation.nova.runtime.multiNetwork.runtime.repository.findEvent
import io.novasama.substrate_sdk_android.runtime.definitions.types.generics.GenericCall
class HydraDxRouterSwapExtractor(
hydraDxAssetIdConverter: HydraDxAssetIdConverter,
) : BaseHydraDxSwapExtractor(hydraDxAssetIdConverter) {
private val calls = listOf("buy", "sell")
override fun isSwap(call: GenericCall.Instance): Boolean {
return call.module.name == Modules.ROUTER &&
call.function.name in calls
}
override fun ExtrinsicVisit.extractSwapArgs(): SwapArgs {
val swapExecutedEvent = events.findEvent(Modules.ROUTER, "RouteExecuted")
?: events.findEvent(Modules.ROUTER, "Executed")
return when {
// successful swap, extract from event
swapExecutedEvent != null -> {
val (assetIn, assetOut, amountIn, amountOut) = swapExecutedEvent.arguments
SwapArgs(
assetIn = bindNumber(assetIn),
assetOut = bindNumber(assetOut),
amountIn = bindNumber(amountIn),
amountOut = bindNumber(amountOut)
)
}
// failed swap, extract from call args
call.function.name == "sell" -> {
SwapArgs(
assetIn = bindNumber(call.arguments["asset_in"]),
assetOut = bindNumber(call.arguments["asset_out"]),
amountIn = bindNumber(call.arguments["amount_in"]),
amountOut = bindNumber(call.arguments["min_amount_out"])
)
}
call.function.name == "buy" -> {
SwapArgs(
assetIn = bindNumber(call.arguments["asset_in"]),
assetOut = bindNumber(call.arguments["asset_out"]),
amountIn = bindNumber(call.arguments["max_amount_in"]),
amountOut = bindNumber(call.arguments["amount_out"])
)
}
else -> error("Unknown call")
}
}
}
@@ -0,0 +1,91 @@
package io.novafoundation.nova.feature_wallet_impl.data.network.blockchain.assets.history.statemine
import io.novafoundation.nova.common.data.network.runtime.binding.bindAccountIdentifier
import io.novafoundation.nova.common.data.network.runtime.binding.bindNumber
import io.novafoundation.nova.common.utils.oneOf
import io.novafoundation.nova.feature_wallet_api.data.network.blockhain.assets.history.realtime.RealtimeHistoryUpdate
import io.novafoundation.nova.feature_wallet_api.data.network.blockhain.assets.history.realtime.substrate.SubstrateRealtimeOperationFetcher
import io.novafoundation.nova.feature_wallet_api.data.network.blockhain.assets.history.realtime.substrate.SubstrateRealtimeOperationFetcher.Factory.Source
import io.novafoundation.nova.feature_wallet_api.data.network.blockhain.assets.history.realtime.substrate.asSource
import io.novafoundation.nova.feature_wallet_api.data.repository.CoinPriceRepository
import io.novafoundation.nova.feature_wallet_api.domain.interfaces.TransactionFilter
import io.novafoundation.nova.feature_wallet_impl.data.network.blockchain.assets.history.SubstrateAssetHistory
import io.novafoundation.nova.feature_wallet_impl.data.network.subquery.SubQueryOperationsApi
import io.novafoundation.nova.feature_wallet_impl.data.storage.TransferCursorStorage
import io.novafoundation.nova.runtime.ext.assetConversionSupported
import io.novafoundation.nova.runtime.ext.isSwapSupported
import io.novafoundation.nova.runtime.ext.isUtilityAsset
import io.novafoundation.nova.runtime.ext.palletNameOrDefault
import io.novafoundation.nova.runtime.ext.requireStatemine
import io.novafoundation.nova.runtime.extrinsic.visitor.extrinsic.api.ExtrinsicVisit
import io.novafoundation.nova.runtime.multiNetwork.ChainRegistry
import io.novafoundation.nova.runtime.multiNetwork.chain.model.Chain
import io.novafoundation.nova.runtime.multiNetwork.chain.model.hasSameId
import io.novafoundation.nova.runtime.multiNetwork.getRuntime
import io.novasama.substrate_sdk_android.runtime.RuntimeSnapshot
import io.novasama.substrate_sdk_android.runtime.definitions.types.generics.GenericCall
import io.novasama.substrate_sdk_android.runtime.metadata.call
import io.novasama.substrate_sdk_android.runtime.metadata.module
class StatemineAssetHistory(
private val chainRegistry: ChainRegistry,
realtimeOperationFetcherFactory: SubstrateRealtimeOperationFetcher.Factory,
walletOperationsApi: SubQueryOperationsApi,
cursorStorage: TransferCursorStorage,
coinPriceRepository: CoinPriceRepository
) : SubstrateAssetHistory(walletOperationsApi, cursorStorage, realtimeOperationFetcherFactory, coinPriceRepository) {
override fun realtimeFetcherSources(chain: Chain): List<Source> {
return buildList {
add(TransferExtractor().asSource())
if (chain.swap.assetConversionSupported()) {
add(Source.Known.Id.ASSET_CONVERSION_SWAP.asSource())
}
}
}
override fun availableOperationFilters(chain: Chain, asset: Chain.Asset): Set<TransactionFilter> {
return setOfNotNull(
TransactionFilter.TRANSFER,
TransactionFilter.EXTRINSIC.takeIf { asset.isUtilityAsset },
TransactionFilter.SWAP.takeIf { chain.isSwapSupported() }
)
}
private inner class TransferExtractor : SubstrateRealtimeOperationFetcher.Extractor {
override suspend fun extractRealtimeHistoryUpdates(
extrinsicVisit: ExtrinsicVisit,
chain: Chain,
chainAsset: Chain.Asset
): RealtimeHistoryUpdate.Type? {
val runtime = chainRegistry.getRuntime(chain.id)
val call = extrinsicVisit.call
if (!call.isTransfer(runtime, chainAsset)) return null
val amount = bindNumber(call.arguments["amount"])
return RealtimeHistoryUpdate.Type.Transfer(
senderId = extrinsicVisit.origin,
recipientId = bindAccountIdentifier(call.arguments["target"]),
amountInPlanks = amount,
chainAsset = chainAsset,
)
}
private fun GenericCall.Instance.isTransfer(runtime: RuntimeSnapshot, chainAsset: Chain.Asset): Boolean {
val statemineType = chainAsset.requireStatemine()
val moduleName = statemineType.palletNameOrDefault()
val module = runtime.metadata.module(moduleName)
val matchingCall = oneOf(
module.call("transfer"),
module.call("transfer_keep_alive"),
)
return matchingCall && statemineType.hasSameId(runtime, dynamicInstanceId = arguments["id"])
}
}
}
@@ -0,0 +1,94 @@
package io.novafoundation.nova.feature_wallet_impl.data.network.blockchain.assets.history.utility
import io.novafoundation.nova.common.data.network.runtime.binding.bindAccountIdentifier
import io.novafoundation.nova.common.data.network.runtime.binding.bindNumber
import io.novafoundation.nova.common.utils.Modules
import io.novafoundation.nova.common.utils.balances
import io.novafoundation.nova.common.utils.oneOf
import io.novafoundation.nova.feature_wallet_api.data.network.blockhain.assets.history.realtime.RealtimeHistoryUpdate
import io.novafoundation.nova.feature_wallet_api.data.network.blockhain.assets.history.realtime.substrate.SubstrateRealtimeOperationFetcher
import io.novafoundation.nova.feature_wallet_api.data.network.blockhain.assets.history.realtime.substrate.SubstrateRealtimeOperationFetcher.Factory.Source
import io.novafoundation.nova.feature_wallet_api.data.network.blockhain.assets.history.realtime.substrate.asSource
import io.novafoundation.nova.feature_wallet_api.data.repository.CoinPriceRepository
import io.novafoundation.nova.feature_wallet_api.domain.interfaces.TransactionFilter
import io.novafoundation.nova.feature_wallet_impl.data.network.blockchain.assets.history.SubstrateAssetHistory
import io.novafoundation.nova.feature_wallet_impl.data.network.subquery.SubQueryOperationsApi
import io.novafoundation.nova.feature_wallet_impl.data.storage.TransferCursorStorage
import io.novafoundation.nova.runtime.ext.assetConversionSupported
import io.novafoundation.nova.runtime.ext.hydraDxSupported
import io.novafoundation.nova.runtime.ext.isSwapSupported
import io.novafoundation.nova.runtime.extrinsic.visitor.extrinsic.api.ExtrinsicVisit
import io.novafoundation.nova.runtime.multiNetwork.ChainRegistry
import io.novafoundation.nova.runtime.multiNetwork.chain.model.Chain
import io.novafoundation.nova.runtime.multiNetwork.getRuntime
import io.novafoundation.nova.runtime.multiNetwork.runtime.repository.findEvent
import io.novasama.substrate_sdk_android.runtime.RuntimeSnapshot
import io.novasama.substrate_sdk_android.runtime.definitions.types.generics.GenericCall
import io.novasama.substrate_sdk_android.runtime.metadata.callOrNull
class NativeAssetHistory(
private val chainRegistry: ChainRegistry,
realtimeOperationFetcherFactory: SubstrateRealtimeOperationFetcher.Factory,
walletOperationsApi: SubQueryOperationsApi,
cursorStorage: TransferCursorStorage,
coinPriceRepository: CoinPriceRepository
) : SubstrateAssetHistory(walletOperationsApi, cursorStorage, realtimeOperationFetcherFactory, coinPriceRepository) {
override fun realtimeFetcherSources(chain: Chain): List<Source> {
return buildList {
add(TransferExtractor().asSource())
if (chain.swap.assetConversionSupported()) {
Source.Known.Id.ASSET_CONVERSION_SWAP.asSource()
}
if (chain.swap.hydraDxSupported()) {
add(Source.Known.Id.HYDRA_DX_SWAP.asSource())
}
}
}
override fun availableOperationFilters(chain: Chain, asset: Chain.Asset): Set<TransactionFilter> {
return setOfNotNull(
TransactionFilter.TRANSFER,
TransactionFilter.EXTRINSIC,
TransactionFilter.REWARD,
TransactionFilter.SWAP.takeIf { chain.isSwapSupported() }
)
}
private inner class TransferExtractor : SubstrateRealtimeOperationFetcher.Extractor {
override suspend fun extractRealtimeHistoryUpdates(
extrinsicVisit: ExtrinsicVisit,
chain: Chain,
chainAsset: Chain.Asset
): RealtimeHistoryUpdate.Type? {
val runtime = chainRegistry.getRuntime(chain.id)
val call = extrinsicVisit.call
if (!call.isTransfer(runtime)) return null
val transferEvent = extrinsicVisit.events.findEvent(Modules.BALANCES, "Transfer") ?: return null
val (_, toRaw, amountRaw) = transferEvent.arguments
return RealtimeHistoryUpdate.Type.Transfer(
senderId = extrinsicVisit.origin,
recipientId = bindAccountIdentifier(toRaw),
amountInPlanks = bindNumber(amountRaw),
chainAsset = chainAsset,
)
}
private fun GenericCall.Instance.isTransfer(runtime: RuntimeSnapshot): Boolean {
val balances = runtime.metadata.balances()
return oneOf(
balances.callOrNull("transfer"),
balances.callOrNull("transfer_keep_alive"),
balances.callOrNull("transfer_allow_death"),
balances.callOrNull("transfer_all")
)
}
}
}
@@ -0,0 +1,118 @@
package io.novafoundation.nova.feature_wallet_impl.data.network.blockchain.assets.transfers
import io.novafoundation.nova.common.validation.ValidationSystem
import io.novafoundation.nova.feature_account_api.data.ethereum.transaction.intoOrigin
import io.novafoundation.nova.feature_account_api.data.extrinsic.ExtrinsicService
import io.novafoundation.nova.feature_account_api.data.extrinsic.ExtrinsicSubmission
import io.novafoundation.nova.feature_account_api.data.extrinsic.createDefault
import io.novafoundation.nova.feature_account_api.data.extrinsic.execution.requireOk
import io.novafoundation.nova.feature_account_api.data.model.Fee
import io.novafoundation.nova.feature_wallet_api.data.network.blockhain.assets.AssetSourceRegistry
import io.novafoundation.nova.feature_wallet_api.data.network.blockhain.assets.tranfers.AssetTransfer
import io.novafoundation.nova.feature_wallet_api.data.network.blockhain.assets.tranfers.AssetTransfers
import io.novafoundation.nova.feature_wallet_api.data.network.blockhain.assets.tranfers.AssetTransfersValidationSystemBuilder
import io.novafoundation.nova.feature_wallet_api.data.network.blockhain.assets.tranfers.TransactionExecution
import io.novafoundation.nova.feature_wallet_api.data.network.blockhain.assets.tranfers.WeightedAssetTransfer
import io.novafoundation.nova.feature_wallet_api.domain.validation.EnoughTotalToStayAboveEDValidationFactory
import io.novafoundation.nova.feature_wallet_api.domain.validation.PhishingValidationFactory
import io.novafoundation.nova.feature_wallet_impl.data.network.blockchain.assets.transfers.validations.doNotCrossExistentialDepositInUsedAsset
import io.novafoundation.nova.feature_wallet_impl.data.network.blockchain.assets.transfers.validations.notDeadRecipientInCommissionAsset
import io.novafoundation.nova.feature_wallet_impl.data.network.blockchain.assets.transfers.validations.notDeadRecipientInUsedAsset
import io.novafoundation.nova.feature_wallet_impl.data.network.blockchain.assets.transfers.validations.notPhishingRecipient
import io.novafoundation.nova.feature_wallet_impl.data.network.blockchain.assets.transfers.validations.positiveAmount
import io.novafoundation.nova.feature_wallet_impl.data.network.blockchain.assets.transfers.validations.recipientIsNotSystemAccount
import io.novafoundation.nova.feature_wallet_impl.data.network.blockchain.assets.transfers.validations.sufficientBalanceInUsedAsset
import io.novafoundation.nova.feature_wallet_impl.data.network.blockchain.assets.transfers.validations.sufficientCommissionBalanceToStayAboveED
import io.novafoundation.nova.feature_wallet_impl.data.network.blockchain.assets.transfers.validations.sufficientTransferableBalanceToPayOriginFee
import io.novafoundation.nova.feature_wallet_impl.data.network.blockchain.assets.transfers.validations.validAddress
import io.novafoundation.nova.feature_wallet_impl.domain.validaiton.recipientCanAcceptTransfer
import io.novafoundation.nova.runtime.multiNetwork.ChainRegistry
import io.novafoundation.nova.runtime.multiNetwork.chain.model.Chain
import io.novafoundation.nova.runtime.multiNetwork.getRuntime
import io.novasama.substrate_sdk_android.runtime.extrinsic.builder.ExtrinsicBuilder
import io.novasama.substrate_sdk_android.runtime.metadata.callOrNull
import io.novasama.substrate_sdk_android.runtime.metadata.moduleOrNull
import kotlinx.coroutines.CoroutineScope
abstract class BaseAssetTransfers(
internal val chainRegistry: ChainRegistry,
private val assetSourceRegistry: AssetSourceRegistry,
private val extrinsicServiceFactory: ExtrinsicService.Factory,
private val phishingValidationFactory: PhishingValidationFactory,
private val enoughTotalToStayAboveEDValidationFactory: EnoughTotalToStayAboveEDValidationFactory
) : AssetTransfers {
protected abstract fun ExtrinsicBuilder.transfer(transfer: AssetTransfer)
/**
* Format: [(Module, Function)]
* Transfers will be enabled if at least one function exists
*/
protected abstract suspend fun transferFunctions(chainAsset: Chain.Asset): List<Pair<String, String>>
override suspend fun performTransfer(transfer: WeightedAssetTransfer, coroutineScope: CoroutineScope): Result<ExtrinsicSubmission> {
val submissionOptions = ExtrinsicService.SubmissionOptions(transfer.feePaymentCurrency)
return extrinsicServiceFactory
.createDefault(coroutineScope)
.submitExtrinsic(transfer.originChain, transfer.sender.intoOrigin(), submissionOptions = submissionOptions) {
transfer(transfer)
}
}
override suspend fun performTransferAndAwaitExecution(transfer: WeightedAssetTransfer, coroutineScope: CoroutineScope): Result<TransactionExecution> {
val submissionOptions = ExtrinsicService.SubmissionOptions(transfer.feePaymentCurrency)
return extrinsicServiceFactory
.createDefault(coroutineScope)
.submitExtrinsicAndAwaitExecution(transfer.originChain, transfer.sender.intoOrigin(), submissionOptions = submissionOptions) {
transfer(transfer)
}
.requireOk()
.map(TransactionExecution::Substrate)
}
override suspend fun calculateFee(transfer: AssetTransfer, coroutineScope: CoroutineScope): Fee {
val submissionOptions = ExtrinsicService.SubmissionOptions(transfer.feePaymentCurrency)
return extrinsicServiceFactory
.createDefault(coroutineScope)
.estimateFee(transfer.originChain, transfer.sender.intoOrigin(), submissionOptions = submissionOptions) {
transfer(transfer)
}
}
override suspend fun areTransfersEnabled(chainAsset: Chain.Asset): Boolean {
val runtime = chainRegistry.getRuntime(chainAsset.chainId)
return transferFunctions(chainAsset).any { (module, function) ->
runtime.metadata.moduleOrNull(module)?.callOrNull(function) != null
}
}
override fun getValidationSystem(coroutineScope: CoroutineScope) = ValidationSystem {
validAddress()
recipientIsNotSystemAccount()
notPhishingRecipient(phishingValidationFactory)
positiveAmount()
sufficientBalanceInUsedAsset()
sufficientTransferableBalanceToPayOriginFee()
sufficientCommissionBalanceToStayAboveED(enoughTotalToStayAboveEDValidationFactory)
notDeadRecipientInUsedAsset(assetSourceRegistry)
notDeadRecipientInCommissionAsset(assetSourceRegistry)
doNotCrossExistentialDeposit()
recipientCanAcceptTransfer(assetSourceRegistry)
}
private fun AssetTransfersValidationSystemBuilder.doNotCrossExistentialDeposit() = doNotCrossExistentialDepositInUsedAsset(
assetSourceRegistry = assetSourceRegistry,
extraAmount = { it.transfer.amount },
)
}
@@ -0,0 +1,43 @@
package io.novafoundation.nova.feature_wallet_impl.data.network.blockchain.assets.transfers
import io.novafoundation.nova.feature_account_api.data.extrinsic.ExtrinsicSubmission
import io.novafoundation.nova.feature_account_api.data.model.Fee
import io.novafoundation.nova.feature_wallet_api.data.network.blockhain.assets.tranfers.AssetTransfer
import io.novafoundation.nova.feature_wallet_api.data.network.blockhain.assets.tranfers.AssetTransfers
import io.novafoundation.nova.feature_wallet_api.data.network.blockhain.assets.tranfers.AssetTransfersValidationSystem
import io.novafoundation.nova.feature_wallet_api.data.network.blockhain.assets.tranfers.TransactionExecution
import io.novafoundation.nova.feature_wallet_api.data.network.blockhain.assets.tranfers.WeightedAssetTransfer
import io.novafoundation.nova.feature_wallet_api.data.network.blockhain.assets.tranfers.model.TransferParsedFromCall
import io.novafoundation.nova.runtime.multiNetwork.chain.model.Chain
import io.novasama.substrate_sdk_android.runtime.definitions.types.generics.GenericCall
import kotlinx.coroutines.CoroutineScope
open class UnsupportedAssetTransfers : AssetTransfers {
override fun getValidationSystem(coroutineScope: CoroutineScope): AssetTransfersValidationSystem {
throw UnsupportedOperationException("Unsupported")
}
override suspend fun calculateFee(transfer: AssetTransfer, coroutineScope: CoroutineScope): Fee {
throw UnsupportedOperationException("Unsupported")
}
override suspend fun performTransfer(transfer: WeightedAssetTransfer, coroutineScope: CoroutineScope): Result<ExtrinsicSubmission> {
return Result.failure(UnsupportedOperationException("Unsupported"))
}
override suspend fun performTransferAndAwaitExecution(
transfer: WeightedAssetTransfer,
coroutineScope: CoroutineScope
): Result<TransactionExecution> {
return Result.failure(UnsupportedOperationException("Unsupported"))
}
override suspend fun areTransfersEnabled(chainAsset: Chain.Asset): Boolean {
return false
}
override suspend fun parseTransfer(call: GenericCall.Instance, chain: Chain): TransferParsedFromCall? {
return null
}
}
@@ -0,0 +1,107 @@
package io.novafoundation.nova.feature_wallet_impl.data.network.blockchain.assets.transfers.equilibrium
import io.novafoundation.nova.common.data.network.runtime.binding.bindBoolean
import io.novafoundation.nova.common.data.network.runtime.binding.returnType
import io.novafoundation.nova.common.utils.Modules
import io.novafoundation.nova.common.utils.eqBalances
import io.novafoundation.nova.common.validation.ValidationSystem
import io.novafoundation.nova.feature_account_api.data.extrinsic.ExtrinsicService
import io.novafoundation.nova.feature_wallet_api.data.network.blockhain.assets.AssetSourceRegistry
import io.novafoundation.nova.feature_wallet_api.data.network.blockhain.assets.tranfers.AssetTransfer
import io.novafoundation.nova.feature_wallet_api.data.network.blockhain.assets.tranfers.model.TransferParsedFromCall
import io.novafoundation.nova.feature_wallet_api.domain.model.planksFromAmount
import io.novafoundation.nova.feature_wallet_api.domain.validation.EnoughTotalToStayAboveEDValidationFactory
import io.novafoundation.nova.feature_wallet_api.domain.validation.PhishingValidationFactory
import io.novafoundation.nova.feature_wallet_impl.data.network.blockchain.assets.transfers.BaseAssetTransfers
import io.novafoundation.nova.feature_wallet_impl.data.network.blockchain.assets.transfers.validations.notDeadRecipientInUsedAsset
import io.novafoundation.nova.feature_wallet_impl.data.network.blockchain.assets.transfers.validations.positiveAmount
import io.novafoundation.nova.feature_wallet_impl.data.network.blockchain.assets.transfers.validations.recipientIsNotSystemAccount
import io.novafoundation.nova.feature_wallet_impl.data.network.blockchain.assets.transfers.validations.sufficientBalanceInUsedAsset
import io.novafoundation.nova.feature_wallet_impl.data.network.blockchain.assets.transfers.validations.sufficientTransferableBalanceToPayOriginFee
import io.novafoundation.nova.feature_wallet_impl.data.network.blockchain.assets.transfers.validations.validAddress
import io.novafoundation.nova.feature_wallet_impl.domain.validaiton.recipientCanAcceptTransfer
import io.novafoundation.nova.runtime.ext.accountIdOrDefault
import io.novafoundation.nova.runtime.ext.requireEquilibrium
import io.novafoundation.nova.runtime.multiNetwork.ChainRegistry
import io.novafoundation.nova.runtime.multiNetwork.chain.model.Chain
import io.novafoundation.nova.runtime.storage.source.StorageDataSource
import io.novasama.substrate_sdk_android.runtime.RuntimeSnapshot
import io.novasama.substrate_sdk_android.runtime.definitions.types.fromHexOrNull
import io.novasama.substrate_sdk_android.runtime.definitions.types.generics.GenericCall
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.metadata.module.StorageEntry
import io.novasama.substrate_sdk_android.runtime.metadata.storage
import io.novasama.substrate_sdk_android.runtime.metadata.storageKey
import kotlinx.coroutines.CoroutineScope
private const val TRANSFER_CALL = "transfer"
class EquilibriumAssetTransfers(
chainRegistry: ChainRegistry,
private val assetSourceRegistry: AssetSourceRegistry,
extrinsicServiceFactory: ExtrinsicService.Factory,
phishingValidationFactory: PhishingValidationFactory,
private val remoteStorageSource: StorageDataSource,
private val enoughTotalToStayAboveEDValidationFactory: EnoughTotalToStayAboveEDValidationFactory
) : BaseAssetTransfers(chainRegistry, assetSourceRegistry, extrinsicServiceFactory, phishingValidationFactory, enoughTotalToStayAboveEDValidationFactory) {
override fun getValidationSystem(coroutineScope: CoroutineScope) = ValidationSystem {
validAddress()
recipientIsNotSystemAccount()
positiveAmount()
sufficientBalanceInUsedAsset()
sufficientTransferableBalanceToPayOriginFee()
notDeadRecipientInUsedAsset(assetSourceRegistry)
recipientCanAcceptTransfer(assetSourceRegistry)
}
override suspend fun parseTransfer(call: GenericCall.Instance, chain: Chain): TransferParsedFromCall? {
return null
}
override fun ExtrinsicBuilder.transfer(transfer: AssetTransfer) {
if (transfer.originChainAsset.type !is Chain.Asset.Type.Equilibrium) return
val accountId = transfer.originChain.accountIdOrDefault(transfer.recipient)
val amount = transfer.originChainAsset.planksFromAmount(transfer.amount)
call(
moduleName = Modules.EQ_BALANCES,
callName = TRANSFER_CALL,
arguments = mapOf(
"asset" to transfer.originChainAsset.requireEquilibrium().id,
"to" to accountId,
"value" to amount
)
)
}
override suspend fun transferFunctions(chainAsset: Chain.Asset): List<Pair<String, String>> {
return listOf(Modules.EQ_BALANCES to TRANSFER_CALL)
}
override suspend fun areTransfersEnabled(chainAsset: Chain.Asset): Boolean {
if (chainAsset.type !is Chain.Asset.Type.Equilibrium) return false
return queryIsTransferEnabledStorage(chainAsset) ?: super.areTransfersEnabled(chainAsset)
}
private suspend fun queryIsTransferEnabledStorage(chainAsset: Chain.Asset): Boolean? {
return remoteStorageSource.query(
chainAsset.chainId,
keyBuilder = { it.getTransferEnabledStorage().storageKey() },
binding = { scale, runtimeSnapshot ->
if (scale == null) return@query null
val returnType = runtimeSnapshot.getTransferEnabledStorage().returnType()
bindBoolean(returnType.fromHexOrNull(runtimeSnapshot, scale))
}
)
}
private fun RuntimeSnapshot.getTransferEnabledStorage(): StorageEntry {
return metadata.eqBalances().storage("IsTransfersEnabled")
}
}
@@ -0,0 +1,105 @@
package io.novafoundation.nova.feature_wallet_impl.data.network.blockchain.assets.transfers.evmErc20
import io.novafoundation.nova.common.validation.ValidationSystem
import io.novafoundation.nova.feature_account_api.data.ethereum.transaction.EvmTransactionService
import io.novafoundation.nova.feature_account_api.data.ethereum.transaction.intoOrigin
import io.novafoundation.nova.feature_account_api.data.extrinsic.ExtrinsicSubmission
import io.novafoundation.nova.feature_account_api.data.model.Fee
import io.novafoundation.nova.feature_wallet_api.data.network.blockhain.assets.AssetSourceRegistry
import io.novafoundation.nova.feature_wallet_api.data.network.blockhain.assets.tranfers.AssetTransfer
import io.novafoundation.nova.feature_wallet_api.data.network.blockhain.assets.tranfers.AssetTransfers
import io.novafoundation.nova.feature_wallet_api.data.network.blockhain.assets.tranfers.TransactionExecution
import io.novafoundation.nova.feature_wallet_api.data.network.blockhain.assets.tranfers.WeightedAssetTransfer
import io.novafoundation.nova.feature_wallet_api.data.network.blockhain.assets.tranfers.amountInPlanks
import io.novafoundation.nova.feature_wallet_api.data.network.blockhain.assets.tranfers.model.TransferParsedFromCall
import io.novafoundation.nova.feature_wallet_impl.data.network.blockchain.assets.transfers.validations.checkForFeeChanges
import io.novafoundation.nova.feature_wallet_impl.data.network.blockchain.assets.transfers.validations.positiveAmount
import io.novafoundation.nova.feature_wallet_impl.data.network.blockchain.assets.transfers.validations.recipientIsNotSystemAccount
import io.novafoundation.nova.feature_wallet_impl.data.network.blockchain.assets.transfers.validations.sufficientBalanceInUsedAsset
import io.novafoundation.nova.feature_wallet_impl.data.network.blockchain.assets.transfers.validations.sufficientTransferableBalanceToPayOriginFee
import io.novafoundation.nova.feature_wallet_impl.data.network.blockchain.assets.transfers.validations.validAddress
import io.novafoundation.nova.feature_wallet_impl.domain.validaiton.recipientCanAcceptTransfer
import io.novafoundation.nova.runtime.ethereum.contract.erc20.Erc20Standard
import io.novafoundation.nova.runtime.ethereum.transaction.builder.EvmTransactionBuilder
import io.novafoundation.nova.runtime.ethereum.transaction.builder.contractCall
import io.novafoundation.nova.runtime.ext.accountIdOrDefault
import io.novafoundation.nova.runtime.ext.requireErc20
import io.novafoundation.nova.runtime.multiNetwork.chain.model.Chain
import io.novasama.substrate_sdk_android.runtime.definitions.types.generics.GenericCall
import kotlinx.coroutines.CoroutineScope
// a conservative upper limit. Usually transfer takes around 30-50k
private val ERC_20_UPPER_GAS_LIMIT = 200_000.toBigInteger()
class EvmErc20AssetTransfers(
private val evmTransactionService: EvmTransactionService,
private val erc20Standard: Erc20Standard,
private val assetSourceRegistry: AssetSourceRegistry,
) : AssetTransfers {
override fun getValidationSystem(coroutineScope: CoroutineScope) = ValidationSystem {
validAddress()
recipientIsNotSystemAccount()
positiveAmount()
sufficientBalanceInUsedAsset()
sufficientTransferableBalanceToPayOriginFee()
recipientCanAcceptTransfer(assetSourceRegistry)
checkForFeeChanges(assetSourceRegistry, coroutineScope)
}
override suspend fun calculateFee(transfer: AssetTransfer, coroutineScope: CoroutineScope): Fee {
return evmTransactionService.calculateFee(
transfer.originChain.id,
fallbackGasLimit = ERC_20_UPPER_GAS_LIMIT,
origin = transfer.sender.intoOrigin()
) {
transfer(transfer)
}
}
override suspend fun performTransfer(transfer: WeightedAssetTransfer, coroutineScope: CoroutineScope): Result<ExtrinsicSubmission> {
return evmTransactionService.transact(
chainId = transfer.originChain.id,
presetFee = transfer.fee.submissionFee,
fallbackGasLimit = ERC_20_UPPER_GAS_LIMIT,
origin = transfer.sender.intoOrigin()
) {
transfer(transfer)
}
}
override suspend fun performTransferAndAwaitExecution(
transfer: WeightedAssetTransfer,
coroutineScope: CoroutineScope
): Result<TransactionExecution> {
return evmTransactionService.transactAndAwaitExecution(
chainId = transfer.originChain.id,
presetFee = transfer.fee.submissionFee,
fallbackGasLimit = ERC_20_UPPER_GAS_LIMIT,
origin = transfer.sender.intoOrigin()
) {
transfer(transfer)
}.map { TransactionExecution.Ethereum(it) }
}
override suspend fun areTransfersEnabled(chainAsset: Chain.Asset): Boolean {
return true
}
override suspend fun parseTransfer(call: GenericCall.Instance, chain: Chain): TransferParsedFromCall? {
return null
}
private fun EvmTransactionBuilder.transfer(transfer: AssetTransfer) {
val erc20 = transfer.originChainAsset.requireErc20()
val recipient = transfer.originChain.accountIdOrDefault(transfer.recipient)
contractCall(erc20.contractAddress, erc20Standard) {
transfer(recipient = recipient, amount = transfer.amountInPlanks)
}
}
}
@@ -0,0 +1,95 @@
package io.novafoundation.nova.feature_wallet_impl.data.network.blockchain.assets.transfers.evmNative
import io.novafoundation.nova.common.validation.ValidationSystem
import io.novafoundation.nova.feature_account_api.data.ethereum.transaction.EvmTransactionService
import io.novafoundation.nova.feature_account_api.data.ethereum.transaction.intoOrigin
import io.novafoundation.nova.feature_account_api.data.extrinsic.ExtrinsicSubmission
import io.novafoundation.nova.feature_account_api.data.model.Fee
import io.novafoundation.nova.feature_wallet_api.data.network.blockhain.assets.AssetSourceRegistry
import io.novafoundation.nova.feature_wallet_api.data.network.blockhain.assets.tranfers.AssetTransfer
import io.novafoundation.nova.feature_wallet_api.data.network.blockhain.assets.tranfers.AssetTransfers
import io.novafoundation.nova.feature_wallet_api.data.network.blockhain.assets.tranfers.WeightedAssetTransfer
import io.novafoundation.nova.feature_wallet_api.data.network.blockhain.assets.tranfers.amountInPlanks
import io.novafoundation.nova.feature_wallet_api.data.network.blockhain.assets.tranfers.model.TransferParsedFromCall
import io.novafoundation.nova.feature_wallet_api.data.network.blockhain.assets.tranfers.TransactionExecution
import io.novafoundation.nova.feature_wallet_impl.data.network.blockchain.assets.transfers.validations.checkForFeeChanges
import io.novafoundation.nova.feature_wallet_impl.data.network.blockchain.assets.transfers.validations.positiveAmount
import io.novafoundation.nova.feature_wallet_impl.data.network.blockchain.assets.transfers.validations.recipientIsNotSystemAccount
import io.novafoundation.nova.feature_wallet_impl.data.network.blockchain.assets.transfers.validations.sufficientBalanceInUsedAsset
import io.novafoundation.nova.feature_wallet_impl.data.network.blockchain.assets.transfers.validations.sufficientTransferableBalanceToPayOriginFee
import io.novafoundation.nova.feature_wallet_impl.data.network.blockchain.assets.transfers.validations.validAddress
import io.novafoundation.nova.feature_wallet_impl.domain.validaiton.recipientCanAcceptTransfer
import io.novafoundation.nova.runtime.ethereum.transaction.builder.EvmTransactionBuilder
import io.novafoundation.nova.runtime.ext.accountIdOrDefault
import io.novafoundation.nova.runtime.multiNetwork.chain.model.Chain
import io.novasama.substrate_sdk_android.runtime.definitions.types.generics.GenericCall
import kotlinx.coroutines.CoroutineScope
// native coin transfer has a fixed fee
private val NATIVE_COIN_TRANSFER_GAS_LIMIT = 21_000.toBigInteger()
class EvmNativeAssetTransfers(
private val evmTransactionService: EvmTransactionService,
private val assetSourceRegistry: AssetSourceRegistry,
) : AssetTransfers {
override fun getValidationSystem(coroutineScope: CoroutineScope) = ValidationSystem {
validAddress()
recipientIsNotSystemAccount()
positiveAmount()
sufficientBalanceInUsedAsset()
sufficientTransferableBalanceToPayOriginFee()
recipientCanAcceptTransfer(assetSourceRegistry)
checkForFeeChanges(assetSourceRegistry, coroutineScope)
}
override suspend fun calculateFee(transfer: AssetTransfer, coroutineScope: CoroutineScope): Fee {
return evmTransactionService.calculateFee(
transfer.originChain.id,
fallbackGasLimit = NATIVE_COIN_TRANSFER_GAS_LIMIT,
origin = transfer.sender.intoOrigin()
) {
nativeTransfer(transfer)
}
}
override suspend fun performTransfer(transfer: WeightedAssetTransfer, coroutineScope: CoroutineScope): Result<ExtrinsicSubmission> {
return evmTransactionService.transact(
chainId = transfer.originChain.id,
fallbackGasLimit = NATIVE_COIN_TRANSFER_GAS_LIMIT,
presetFee = transfer.fee.submissionFee,
origin = transfer.sender.intoOrigin()
) {
nativeTransfer(transfer)
}
}
override suspend fun performTransferAndAwaitExecution(transfer: WeightedAssetTransfer, coroutineScope: CoroutineScope): Result<TransactionExecution> {
return evmTransactionService.transactAndAwaitExecution(
chainId = transfer.originChain.id,
fallbackGasLimit = NATIVE_COIN_TRANSFER_GAS_LIMIT,
presetFee = transfer.fee.submissionFee,
origin = transfer.sender.intoOrigin()
) {
nativeTransfer(transfer)
}.map { TransactionExecution.Ethereum(it) }
}
override suspend fun areTransfersEnabled(chainAsset: Chain.Asset): Boolean {
return true
}
override suspend fun parseTransfer(call: GenericCall.Instance, chain: Chain): TransferParsedFromCall? {
return null
}
private fun EvmTransactionBuilder.nativeTransfer(transfer: AssetTransfer) {
val recipient = transfer.originChain.accountIdOrDefault(transfer.recipient)
nativeTransfer(transfer.amountInPlanks, recipient)
}
}
@@ -0,0 +1,97 @@
package io.novafoundation.nova.feature_wallet_impl.data.network.blockchain.assets.transfers.orml
import io.novafoundation.nova.common.address.intoKey
import io.novafoundation.nova.common.data.network.runtime.binding.bindAccountIdentifier
import io.novafoundation.nova.common.data.network.runtime.binding.bindNumber
import io.novafoundation.nova.common.utils.Modules
import io.novafoundation.nova.common.utils.firstExistingCall
import io.novafoundation.nova.common.utils.instanceOf
import io.novafoundation.nova.feature_account_api.data.extrinsic.ExtrinsicService
import io.novafoundation.nova.feature_wallet_api.data.network.blockhain.assets.AssetSourceRegistry
import io.novafoundation.nova.feature_wallet_api.data.network.blockhain.assets.tranfers.AssetTransfer
import io.novafoundation.nova.feature_wallet_api.data.network.blockhain.assets.tranfers.amountInPlanks
import io.novafoundation.nova.feature_wallet_api.data.network.blockhain.assets.tranfers.model.TransferParsedFromCall
import io.novafoundation.nova.feature_wallet_api.domain.model.withAmount
import io.novafoundation.nova.feature_wallet_api.domain.validation.EnoughTotalToStayAboveEDValidationFactory
import io.novafoundation.nova.feature_wallet_api.domain.validation.PhishingValidationFactory
import io.novafoundation.nova.feature_wallet_impl.data.network.blockchain.assets.transfers.BaseAssetTransfers
import io.novafoundation.nova.runtime.ext.accountIdOrDefault
import io.novafoundation.nova.runtime.ext.findAssetByOrmlCurrencyId
import io.novafoundation.nova.runtime.ext.ormlCurrencyId
import io.novafoundation.nova.runtime.ext.requireOrml
import io.novafoundation.nova.runtime.multiNetwork.ChainRegistry
import io.novafoundation.nova.runtime.multiNetwork.chain.model.Chain
import io.novafoundation.nova.runtime.multiNetwork.getRuntime
import io.novasama.substrate_sdk_android.runtime.AccountId
import io.novasama.substrate_sdk_android.runtime.definitions.types.generics.GenericCall
import io.novafoundation.nova.common.utils.PezkuwiAddressConstructor
import io.novasama.substrate_sdk_android.runtime.extrinsic.builder.ExtrinsicBuilder
import io.novasama.substrate_sdk_android.runtime.extrinsic.call
import java.math.BigInteger
open class OrmlAssetTransfers(
chainRegistry: ChainRegistry,
assetSourceRegistry: AssetSourceRegistry,
extrinsicServiceFactory: ExtrinsicService.Factory,
phishingValidationFactory: PhishingValidationFactory,
enoughTotalToStayAboveEDValidationFactory: EnoughTotalToStayAboveEDValidationFactory
) : BaseAssetTransfers(chainRegistry, assetSourceRegistry, extrinsicServiceFactory, phishingValidationFactory, enoughTotalToStayAboveEDValidationFactory) {
open val transferFunctions = listOf(
Modules.CURRENCIES to "transfer",
Modules.TOKENS to "transfer"
)
override fun ExtrinsicBuilder.transfer(transfer: AssetTransfer) {
ormlTransfer(
chainAsset = transfer.originChainAsset,
target = transfer.originChain.accountIdOrDefault(transfer.recipient),
amount = transfer.amountInPlanks
)
}
override suspend fun transferFunctions(chainAsset: Chain.Asset) = transferFunctions
override suspend fun areTransfersEnabled(chainAsset: Chain.Asset): Boolean {
// flag from chains json AND existence of module & function in runtime metadata
return chainAsset.requireOrml().transfersEnabled && super.areTransfersEnabled(chainAsset)
}
override suspend fun parseTransfer(call: GenericCall.Instance, chain: Chain): TransferParsedFromCall? {
val isOurs = transferFunctions.any { call.instanceOf(it.first, it.second) }
if (!isOurs) return null
val onChainAssetId = call.arguments["currency_id"]
val chainAsset = determineAsset(chain, onChainAssetId) ?: return null
val amount = bindNumber(call.arguments["amount"])
val destination = bindAccountIdentifier(call.arguments["dest"]).intoKey()
return TransferParsedFromCall(
amount = chainAsset.withAmount(amount),
destination = destination
)
}
private suspend fun determineAsset(chain: Chain, onChainAssetId: Any?): Chain.Asset? {
val runtime = chainRegistry.getRuntime(chain.id)
return chain.findAssetByOrmlCurrencyId(runtime, onChainAssetId)
}
private fun ExtrinsicBuilder.ormlTransfer(
chainAsset: Chain.Asset,
target: AccountId,
amount: BigInteger
) {
val (moduleIndex, callIndex) = runtime.metadata.firstExistingCall(transferFunctions).index
call(
moduleIndex = moduleIndex,
callIndex = callIndex,
arguments = mapOf(
"dest" to PezkuwiAddressConstructor.constructInstance(runtime.typeRegistry, target),
"currency_id" to chainAsset.ormlCurrencyId(runtime),
"amount" to amount
)
)
}
}
@@ -0,0 +1,33 @@
package io.novafoundation.nova.feature_wallet_impl.data.network.blockchain.assets.transfers.orml.hydrationEvm
import io.novafoundation.nova.common.di.scope.FeatureScope
import io.novafoundation.nova.common.utils.Modules
import io.novafoundation.nova.feature_account_api.data.extrinsic.ExtrinsicService
import io.novafoundation.nova.feature_wallet_api.data.network.blockhain.assets.AssetSourceRegistry
import io.novafoundation.nova.feature_wallet_api.domain.validation.EnoughTotalToStayAboveEDValidationFactory
import io.novafoundation.nova.feature_wallet_api.domain.validation.PhishingValidationFactory
import io.novafoundation.nova.feature_wallet_impl.data.network.blockchain.assets.transfers.orml.OrmlAssetTransfers
import io.novafoundation.nova.runtime.multiNetwork.ChainRegistry
import javax.inject.Inject
@FeatureScope
class HydrationEvmAssetTransfers @Inject constructor(
chainRegistry: ChainRegistry,
assetSourceRegistry: AssetSourceRegistry,
extrinsicServiceFactory: ExtrinsicService.Factory,
phishingValidationFactory: PhishingValidationFactory,
enoughTotalToStayAboveEDValidationFactory: EnoughTotalToStayAboveEDValidationFactory
) : OrmlAssetTransfers(
chainRegistry = chainRegistry,
assetSourceRegistry = assetSourceRegistry,
extrinsicServiceFactory = extrinsicServiceFactory,
phishingValidationFactory = phishingValidationFactory,
enoughTotalToStayAboveEDValidationFactory = enoughTotalToStayAboveEDValidationFactory
) {
// Force Hydration Evm implementation to always use Currencies.transfer
// Since Tokens.transfer fail for such tokens
override val transferFunctions = listOf(
Modules.CURRENCIES to "transfer",
)
}
@@ -0,0 +1,117 @@
package io.novafoundation.nova.feature_wallet_impl.data.network.blockchain.assets.transfers.statemine
import io.novafoundation.nova.common.address.intoKey
import io.novafoundation.nova.common.data.network.runtime.binding.bindAccountIdentifier
import io.novafoundation.nova.common.data.network.runtime.binding.bindNumber
import io.novafoundation.nova.feature_account_api.data.extrinsic.ExtrinsicService
import io.novafoundation.nova.feature_wallet_api.data.network.blockhain.assets.AssetSourceRegistry
import io.novafoundation.nova.feature_wallet_api.data.network.blockhain.assets.tranfers.AssetTransfer
import io.novafoundation.nova.feature_wallet_api.data.network.blockhain.assets.tranfers.amountInPlanks
import io.novafoundation.nova.feature_wallet_api.data.network.blockhain.assets.tranfers.model.TransferParsedFromCall
import io.novafoundation.nova.feature_wallet_api.domain.model.withAmount
import io.novafoundation.nova.feature_wallet_api.domain.validation.EnoughTotalToStayAboveEDValidationFactory
import io.novafoundation.nova.feature_wallet_api.domain.validation.PhishingValidationFactory
import io.novafoundation.nova.feature_wallet_impl.data.network.blockchain.assets.balances.statemine.canAcceptFunds
import io.novafoundation.nova.feature_wallet_impl.data.network.blockchain.assets.common.bindAssetAccountOrEmpty
import io.novafoundation.nova.feature_wallet_impl.data.network.blockchain.assets.common.statemineModule
import io.novafoundation.nova.feature_wallet_impl.data.network.blockchain.assets.transfers.BaseAssetTransfers
import io.novafoundation.nova.runtime.ext.accountIdOrDefault
import io.novafoundation.nova.runtime.ext.findAssetByStatemineAssetId
import io.novafoundation.nova.runtime.ext.findStatemineAssets
import io.novafoundation.nova.runtime.ext.palletNameOrDefault
import io.novafoundation.nova.runtime.ext.requireStatemine
import io.novafoundation.nova.runtime.multiNetwork.ChainRegistry
import io.novafoundation.nova.runtime.multiNetwork.chain.model.Chain
import io.novafoundation.nova.runtime.multiNetwork.chain.model.prepareIdForEncoding
import io.novafoundation.nova.runtime.multiNetwork.getRuntime
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.GenericCall
import io.novafoundation.nova.common.utils.PezkuwiAddressConstructor
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.metadata.storage
import java.math.BigInteger
class StatemineAssetTransfers(
chainRegistry: ChainRegistry,
assetSourceRegistry: AssetSourceRegistry,
extrinsicServiceFactory: ExtrinsicService.Factory,
phishingValidationFactory: PhishingValidationFactory,
enoughTotalToStayAboveEDValidationFactory: EnoughTotalToStayAboveEDValidationFactory,
private val remoteStorage: StorageDataSource
) : BaseAssetTransfers(chainRegistry, assetSourceRegistry, extrinsicServiceFactory, phishingValidationFactory, enoughTotalToStayAboveEDValidationFactory) {
override suspend fun transferFunctions(chainAsset: Chain.Asset): List<Pair<String, String>> {
val type = chainAsset.requireStatemine()
return listOf(type.palletNameOrDefault() to "transfer")
}
override fun ExtrinsicBuilder.transfer(transfer: AssetTransfer) {
val chainAssetType = transfer.originChainAsset.type
require(chainAssetType is Chain.Asset.Type.Statemine)
statemineTransfer(
assetType = chainAssetType,
target = transfer.originChain.accountIdOrDefault(transfer.recipient),
amount = transfer.amountInPlanks
)
}
override suspend fun recipientCanAcceptTransfer(chainAsset: Chain.Asset, recipient: AccountId): Boolean {
val statemineType = chainAsset.requireStatemine()
val assetAccount = remoteStorage.query(chainAsset.chainId) {
runtime.metadata.statemineModule(statemineType).storage("Account").query(
statemineType.prepareIdForEncoding(runtime),
recipient,
binding = ::bindAssetAccountOrEmpty
)
}
return assetAccount.canAcceptFunds
}
override suspend fun parseTransfer(call: GenericCall.Instance, chain: Chain): TransferParsedFromCall? {
if (!checkIsOurCall(call, chain)) return null
val onChainAssetId = call.arguments["id"]
val chainAsset = determineAsset(chain, onChainAssetId) ?: return null
val amount = bindNumber(call.arguments["amount"])
val destination = bindAccountIdentifier(call.arguments["target"]).intoKey()
return TransferParsedFromCall(
amount = chainAsset.withAmount(amount),
destination = destination
)
}
private suspend fun determineAsset(chain: Chain, onChainAssetId: Any?): Chain.Asset? {
val runtime = chainRegistry.getRuntime(chain.id)
return chain.findAssetByStatemineAssetId(runtime, onChainAssetId)
}
private fun checkIsOurCall(call: GenericCall.Instance, chain: Chain): Boolean {
if (call.function.name != "transfer") return false
val allStatemineAssetsOnChain = chain.findStatemineAssets()
return allStatemineAssetsOnChain.any { it.requireStatemine().palletNameOrDefault() == call.module.name }
}
private fun ExtrinsicBuilder.statemineTransfer(
assetType: Chain.Asset.Type.Statemine,
target: AccountId,
amount: BigInteger
) {
call(
moduleName = assetType.palletNameOrDefault(),
callName = "transfer",
arguments = mapOf(
"id" to assetType.prepareIdForEncoding(runtime),
"target" to PezkuwiAddressConstructor.constructInstance(runtime.typeRegistry, target),
"amount" to amount
)
)
}
}
@@ -0,0 +1,105 @@
package io.novafoundation.nova.feature_wallet_impl.data.network.blockchain.assets.transfers.utility
import io.novafoundation.nova.common.address.intoKey
import io.novafoundation.nova.common.data.network.runtime.binding.bindAccountIdentifier
import io.novafoundation.nova.common.data.network.runtime.binding.bindAccountInfo
import io.novafoundation.nova.common.data.network.runtime.binding.bindNumber
import io.novafoundation.nova.common.utils.Modules
import io.novafoundation.nova.common.utils.instanceOf
import io.novafoundation.nova.common.utils.isZero
import io.novafoundation.nova.feature_account_api.data.extrinsic.ExtrinsicService
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.requireAccountIdIn
import io.novafoundation.nova.feature_wallet_api.data.network.blockhain.TransferMode
import io.novafoundation.nova.feature_wallet_api.data.network.blockhain.assets.AssetSourceRegistry
import io.novafoundation.nova.feature_wallet_api.data.network.blockhain.assets.tranfers.AssetTransfer
import io.novafoundation.nova.feature_wallet_api.data.network.blockhain.assets.tranfers.model.TransferParsedFromCall
import io.novafoundation.nova.feature_wallet_api.data.network.blockhain.nativeTransfer
import io.novafoundation.nova.feature_wallet_api.domain.model.planksFromAmount
import io.novafoundation.nova.feature_wallet_api.domain.model.withAmount
import io.novafoundation.nova.feature_wallet_api.domain.validation.EnoughTotalToStayAboveEDValidationFactory
import io.novafoundation.nova.feature_wallet_api.domain.validation.PhishingValidationFactory
import io.novafoundation.nova.feature_wallet_impl.data.network.blockchain.assets.transfers.BaseAssetTransfers
import io.novafoundation.nova.runtime.ext.accountIdOrDefault
import io.novafoundation.nova.runtime.ext.utilityAsset
import io.novafoundation.nova.runtime.multiNetwork.ChainRegistry
import io.novafoundation.nova.runtime.multiNetwork.chain.model.Chain
import io.novafoundation.nova.runtime.storage.source.StorageDataSource
import io.novasama.substrate_sdk_android.runtime.RuntimeSnapshot
import io.novasama.substrate_sdk_android.runtime.definitions.types.generics.GenericCall
import io.novasama.substrate_sdk_android.runtime.extrinsic.builder.ExtrinsicBuilder
import io.novasama.substrate_sdk_android.runtime.metadata.module
import io.novasama.substrate_sdk_android.runtime.metadata.storage
import io.novasama.substrate_sdk_android.runtime.metadata.storageKey
class NativeAssetTransfers(
chainRegistry: ChainRegistry,
assetSourceRegistry: AssetSourceRegistry,
extrinsicServiceFactory: ExtrinsicService.Factory,
phishingValidationFactory: PhishingValidationFactory,
enoughTotalToStayAboveEDValidationFactory: EnoughTotalToStayAboveEDValidationFactory,
private val storageDataSource: StorageDataSource,
private val accountRepository: AccountRepository,
) : BaseAssetTransfers(chainRegistry, assetSourceRegistry, extrinsicServiceFactory, phishingValidationFactory, enoughTotalToStayAboveEDValidationFactory) {
companion object {
private const val TRANSFER_ALL = "transfer_all"
private const val TRANSFER = "transfer"
private const val TRANSFER_KEEP_ALIVE = "transfer_keep_alive"
private const val TRANSFER_ALLOW_DEATH = "transfer_allow_death"
}
private val parsableCalls = listOf(TRANSFER, TRANSFER_KEEP_ALIVE, TRANSFER_ALLOW_DEATH)
override suspend fun totalCanDropBelowMinimumBalance(chainAsset: Chain.Asset): Boolean {
val chain = chainRegistry.getChain(chainAsset.chainId)
val metaAccount = accountRepository.getSelectedMetaAccount()
val accountInfo = storageDataSource.query(
chainAsset.chainId,
keyBuilder = { getAccountInfoStorageKey(metaAccount, chain, it) },
binding = { it, runtime -> it?.let { bindAccountInfo(it, runtime) } }
)
return accountInfo != null && accountInfo.consumers.isZero
}
override suspend fun parseTransfer(call: GenericCall.Instance, chain: Chain): TransferParsedFromCall? {
val isOurs = parsableCalls.any { call.instanceOf(Modules.BALANCES, it) }
if (!isOurs) return null
val asset = chain.utilityAsset
val amount = bindNumber(call.arguments["value"])
val recipient = bindAccountIdentifier(call.arguments["dest"]).intoKey()
return TransferParsedFromCall(asset.withAmount(amount), recipient)
}
override fun ExtrinsicBuilder.transfer(transfer: AssetTransfer) {
nativeTransfer(
accountId = transfer.originChain.accountIdOrDefault(transfer.recipient),
amount = transfer.originChainAsset.planksFromAmount(transfer.amount),
mode = transfer.transferMode
)
}
override suspend fun transferFunctions(chainAsset: Chain.Asset) = listOf(
Modules.BALANCES to TRANSFER,
Modules.BALANCES to TRANSFER_ALLOW_DEATH,
Modules.BALANCES to TRANSFER_ALL
)
private fun getAccountInfoStorageKey(metaAccount: MetaAccount, chain: Chain, runtime: RuntimeSnapshot): String {
val accountId = metaAccount.requireAccountIdIn(chain)
return runtime.metadata.module(Modules.SYSTEM).storage("Account").storageKey(runtime, accountId)
}
private val AssetTransfer.transferMode: TransferMode
get() = if (transferringMaxAmount) {
TransferMode.ALL
} else {
TransferMode.ALLOW_DEATH
}
}
@@ -0,0 +1,125 @@
package io.novafoundation.nova.feature_wallet_impl.data.network.blockchain.assets.transfers.validations
import io.novafoundation.nova.feature_account_api.domain.validation.notSystemAccount
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.data.network.blockhain.assets.tranfers.AssetTransfer
import io.novafoundation.nova.feature_wallet_api.data.network.blockhain.assets.tranfers.AssetTransferPayload
import io.novafoundation.nova.feature_wallet_api.data.network.blockhain.assets.tranfers.AssetTransferValidationFailure
import io.novafoundation.nova.feature_wallet_api.data.network.blockhain.assets.tranfers.AssetTransferValidationFailure.WillRemoveAccount
import io.novafoundation.nova.feature_wallet_api.data.network.blockhain.assets.tranfers.AssetTransfersValidationSystemBuilder
import io.novafoundation.nova.feature_wallet_api.data.network.blockhain.assets.tranfers.commissionChainAsset
import io.novafoundation.nova.feature_wallet_api.data.network.blockhain.assets.tranfers.originFeeList
import io.novafoundation.nova.feature_wallet_api.data.network.blockhain.assets.tranfers.originFeeListInUsedAsset
import io.novafoundation.nova.feature_wallet_api.data.network.blockhain.assets.tranfers.recipientOrNull
import io.novafoundation.nova.feature_wallet_api.data.network.blockhain.assets.tranfers.sendingAmountInCommissionAsset
import io.novafoundation.nova.feature_wallet_api.domain.model.balanceCountedTowardsED
import io.novafoundation.nova.feature_wallet_api.domain.validation.AmountProducer
import io.novafoundation.nova.feature_wallet_api.domain.validation.EnoughTotalToStayAboveEDValidationFactory
import io.novafoundation.nova.feature_wallet_api.domain.validation.PhishingValidationFactory
import io.novafoundation.nova.feature_wallet_api.domain.validation.checkForFeeChanges
import io.novafoundation.nova.feature_wallet_api.domain.validation.doNotCrossExistentialDepositMultiFee
import io.novafoundation.nova.feature_wallet_api.domain.validation.notPhishingAccount
import io.novafoundation.nova.feature_wallet_api.domain.validation.positiveAmount
import io.novafoundation.nova.feature_wallet_api.domain.validation.sufficientBalance
import io.novafoundation.nova.feature_wallet_api.domain.validation.sufficientBalanceMultiFee
import io.novafoundation.nova.feature_wallet_api.domain.validation.validAddress
import io.novafoundation.nova.feature_wallet_api.domain.validation.validate
import io.novafoundation.nova.runtime.multiNetwork.ChainWithAsset
import io.novafoundation.nova.runtime.multiNetwork.chain.model.Chain
import io.novafoundation.nova.runtime.multiNetwork.chain.model.Chain.Asset.Type
import kotlinx.coroutines.CoroutineScope
import java.math.BigDecimal
fun AssetTransfersValidationSystemBuilder.positiveAmount() = positiveAmount(
amount = { it.transfer.amount },
error = { AssetTransferValidationFailure.NonPositiveAmount }
)
fun AssetTransfersValidationSystemBuilder.notPhishingRecipient(
factory: PhishingValidationFactory
) = notPhishingAccount(
factory = factory,
address = { it.transfer.recipient },
chain = { it.transfer.destinationChain },
warning = AssetTransferValidationFailure::PhishingRecipient
)
fun AssetTransfersValidationSystemBuilder.validAddress() = validAddress(
address = { it.transfer.recipient },
chain = { it.transfer.destinationChain },
error = { AssetTransferValidationFailure.InvalidRecipientAddress(it.transfer.destinationChain) }
)
fun AssetTransfersValidationSystemBuilder.sufficientCommissionBalanceToStayAboveED(
enoughTotalToStayAboveEDValidationFactory: EnoughTotalToStayAboveEDValidationFactory
) {
enoughTotalToStayAboveEDValidationFactory.validate(
fee = { it.originFee.submissionFee },
balance = { it.originCommissionAsset.balanceCountedTowardsED() },
chainWithAsset = { ChainWithAsset(it.transfer.originChain, it.commissionChainAsset) },
error = { payload, error -> AssetTransferValidationFailure.NotEnoughFunds.ToStayAboveED(payload.commissionChainAsset, error) }
)
}
fun AssetTransfersValidationSystemBuilder.checkForFeeChanges(
assetSourceRegistry: AssetSourceRegistry,
coroutineScope: CoroutineScope
) = checkForFeeChanges(
calculateFee = { payload ->
val transfers = assetSourceRegistry.sourceFor(payload.transfer.originChainAsset).transfers
transfers.calculateFee(payload.transfer, coroutineScope)
},
currentFee = { it.originFee.submissionFee },
chainAsset = { it.commissionChainAsset },
error = AssetTransferValidationFailure::FeeChangeDetected
)
fun AssetTransfersValidationSystemBuilder.doNotCrossExistentialDepositInUsedAsset(
assetSourceRegistry: AssetSourceRegistry,
extraAmount: AmountProducer<AssetTransferPayload>,
) = doNotCrossExistentialDepositMultiFee(
countableTowardsEdBalance = { it.originUsedAsset.balanceCountedTowardsED() },
fee = { it.originFeeListInUsedAsset },
extraAmount = extraAmount,
existentialDeposit = { assetSourceRegistry.existentialDepositForUsedAsset(it.transfer) },
error = { remainingAmount, payload -> payload.transfer.originChainAsset.existentialDepositError(remainingAmount) }
)
fun AssetTransfersValidationSystemBuilder.sufficientTransferableBalanceToPayOriginFee() = sufficientBalanceMultiFee(
available = { it.originCommissionAsset.transferable },
amount = { it.sendingAmountInCommissionAsset },
feeExtractor = { it.originFeeList },
error = { context ->
AssetTransferValidationFailure.NotEnoughFunds.InCommissionAsset(
chainAsset = context.payload.commissionChainAsset,
fee = context.fee,
maxUsable = context.maxUsable
)
}
)
fun AssetTransfersValidationSystemBuilder.sufficientBalanceInUsedAsset() = sufficientBalance(
available = { it.originUsedAsset.transferable },
amount = { it.transfer.amount },
fee = { null },
error = { AssetTransferValidationFailure.NotEnoughFunds.InUsedAsset }
)
fun AssetTransfersValidationSystemBuilder.recipientIsNotSystemAccount() = notSystemAccount(
accountId = { it.transfer.recipientOrNull() },
error = { AssetTransferValidationFailure.RecipientIsSystemAccount }
)
private suspend fun AssetSourceRegistry.existentialDepositForUsedAsset(transfer: AssetTransfer): BigDecimal {
return existentialDeposit(transfer.originChainAsset)
}
private fun Chain.Asset.existentialDepositError(amount: BigDecimal): WillRemoveAccount = when (type) {
is Type.Native -> WillRemoveAccount.WillBurnDust
is Type.Orml -> WillRemoveAccount.WillBurnDust
is Type.Statemine -> WillRemoveAccount.WillTransferDust(amount)
is Type.EvmErc20, is Type.EvmNative -> WillRemoveAccount.WillBurnDust
is Type.Equilibrium -> WillRemoveAccount.WillBurnDust
Type.Unsupported -> throw IllegalArgumentException("Unsupported")
}
@@ -0,0 +1,83 @@
package io.novafoundation.nova.feature_wallet_impl.data.network.blockchain.assets.transfers.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_wallet_api.data.network.blockhain.assets.AssetSourceRegistry
import io.novafoundation.nova.feature_wallet_api.data.network.blockhain.assets.tranfers.AssetTransferPayload
import io.novafoundation.nova.feature_wallet_api.data.network.blockhain.assets.tranfers.AssetTransferValidationFailure
import io.novafoundation.nova.feature_wallet_api.data.network.blockhain.assets.tranfers.AssetTransfersValidation
import io.novafoundation.nova.feature_wallet_api.data.network.blockhain.assets.tranfers.AssetTransfersValidationSystemBuilder
import io.novafoundation.nova.feature_wallet_api.data.network.blockhain.assets.tranfers.amountInPlanks
import io.novafoundation.nova.feature_wallet_api.data.network.blockhain.assets.tranfers.receivingAmountInCommissionAsset
import io.novafoundation.nova.feature_wallet_api.domain.validation.PlanksProducer
import io.novafoundation.nova.runtime.ext.accountIdOf
import io.novafoundation.nova.runtime.ext.commissionAsset
import io.novafoundation.nova.runtime.multiNetwork.chain.model.Chain
import java.math.BigInteger
class DeadRecipientValidation(
private val assetSourceRegistry: AssetSourceRegistry,
private val addingAmount: PlanksProducer<AssetTransferPayload>,
private val assetToCheck: (AssetTransferPayload) -> Chain.Asset,
private val skipIf: suspend (AssetTransferPayload) -> Boolean,
private val failure: (AssetTransferPayload) -> AssetTransferValidationFailure.DeadRecipient,
) : AssetTransfersValidation {
override suspend fun validate(value: AssetTransferPayload): ValidationStatus<AssetTransferValidationFailure> {
if (skipIf(value)) {
return valid()
}
val chain = value.transfer.destinationChain
val chainAsset = assetToCheck(value)
val balanceSource = assetSourceRegistry.sourceFor(chainAsset).balance
val existentialDeposit = balanceSource.existentialDeposit(chainAsset)
val recipientAccountId = value.transfer.destinationChain.accountIdOf(value.transfer.recipient)
val recipientBalance = balanceSource.queryAccountBalance(chain, chainAsset, recipientAccountId).countedTowardsEd
return validOrError(recipientBalance + addingAmount(value) >= existentialDeposit) {
failure(value)
}
}
}
fun AssetTransfersValidationSystemBuilder.notDeadRecipientInCommissionAsset(
assetSourceRegistry: AssetSourceRegistry
) = notDeadRecipient(
assetSourceRegistry = assetSourceRegistry,
assetToCheck = { it.transfer.destinationChain.commissionAsset },
addingAmount = { it.receivingAmountInCommissionAsset },
skipIf = { assetSourceRegistry.isAssetSelfSufficient(it.transfer.destinationChainAsset) },
failure = { AssetTransferValidationFailure.DeadRecipient.InCommissionAsset(commissionAsset = it.transfer.destinationChain.commissionAsset) }
)
private suspend fun AssetSourceRegistry.isAssetSelfSufficient(asset: Chain.Asset) = sourceFor(asset).balance.isSelfSufficient(asset)
fun AssetTransfersValidationSystemBuilder.notDeadRecipientInUsedAsset(
assetSourceRegistry: AssetSourceRegistry
) = notDeadRecipient(
assetSourceRegistry = assetSourceRegistry,
assetToCheck = { it.transfer.destinationChainAsset },
addingAmount = { it.transfer.amountInPlanks },
failure = { AssetTransferValidationFailure.DeadRecipient.InUsedAsset }
)
fun AssetTransfersValidationSystemBuilder.notDeadRecipient(
assetSourceRegistry: AssetSourceRegistry,
failure: (AssetTransferPayload) -> AssetTransferValidationFailure.DeadRecipient,
assetToCheck: (AssetTransferPayload) -> Chain.Asset,
addingAmount: PlanksProducer<AssetTransferPayload> = { BigInteger.ZERO },
skipIf: suspend (AssetTransferPayload) -> Boolean = { false }
) = validate(
DeadRecipientValidation(
assetSourceRegistry = assetSourceRegistry,
addingAmount = addingAmount,
assetToCheck = assetToCheck,
failure = failure,
skipIf = skipIf
)
)
@@ -0,0 +1,33 @@
package io.novafoundation.nova.feature_wallet_impl.data.network.blockchain.calls
import io.novafoundation.nova.common.utils.Modules
import io.novafoundation.nova.common.utils.composeCall
import io.novafoundation.nova.feature_xcm_api.runtimeApi.dryRun.model.OriginCaller
import io.novasama.substrate_sdk_android.runtime.RuntimeSnapshot
import io.novasama.substrate_sdk_android.runtime.definitions.types.generics.GenericCall
fun RuntimeSnapshot.composeDispatchAs(
call: GenericCall.Instance,
origin: OriginCaller
): GenericCall.Instance {
return composeCall(
moduleName = Modules.UTILITY,
callName = "dispatch_as",
arguments = mapOf(
"as_origin" to origin.toEncodableInstance(),
"call" to call
)
)
}
fun RuntimeSnapshot.composeBatchAll(
calls: List<GenericCall.Instance>,
): GenericCall.Instance {
return composeCall(
moduleName = Modules.UTILITY,
callName = "batch_all",
arguments = mapOf(
"calls" to calls
)
)
}
@@ -0,0 +1,155 @@
package io.novafoundation.nova.feature_wallet_impl.data.network.blockchain.updaters.balance
import android.util.Log
import io.novafoundation.nova.common.utils.LOG_TAG
import io.novafoundation.nova.common.utils.mergeIfMultiple
import io.novafoundation.nova.core.updater.SharedRequestsBuilder
import io.novafoundation.nova.core.updater.Updater
import io.novafoundation.nova.core_db.dao.OperationDao
import io.novafoundation.nova.core_db.model.operation.OperationBaseLocal
import io.novafoundation.nova.core_db.model.operation.OperationLocal
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_account_api.domain.updaters.AccountUpdateScope
import io.novafoundation.nova.feature_wallet_api.data.mappers.mapAssetWithAmountToLocal
import io.novafoundation.nova.feature_wallet_api.data.mappers.mapOperationStatusToOperationLocalStatus
import io.novafoundation.nova.feature_wallet_api.data.network.blockhain.assets.AssetSourceRegistry
import io.novafoundation.nova.feature_wallet_api.data.network.blockhain.assets.balances.BalanceSyncUpdate
import io.novafoundation.nova.feature_wallet_api.data.network.blockhain.assets.history.AssetHistory
import io.novafoundation.nova.feature_wallet_api.data.network.blockhain.assets.history.realtime.RealtimeHistoryUpdate
import io.novafoundation.nova.runtime.ext.addressOf
import io.novafoundation.nova.runtime.ext.enabledAssets
import io.novafoundation.nova.runtime.ext.localId
import io.novafoundation.nova.runtime.multiNetwork.chain.model.Chain
import io.novasama.substrate_sdk_android.runtime.AccountId
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.catch
import kotlinx.coroutines.flow.onEach
internal class FullSyncPaymentUpdater(
private val operationDao: OperationDao,
private val assetSourceRegistry: AssetSourceRegistry,
override val scope: AccountUpdateScope,
private val chain: Chain,
) : Updater<MetaAccount> {
override val requiredModules: List<String> = emptyList()
override suspend fun listenForUpdates(
storageSubscriptionBuilder: SharedRequestsBuilder,
scopeValue: MetaAccount,
): Flow<Updater.SideEffect> {
val accountId = scopeValue.requireAccountIdIn(chain)
return chain.enabledAssets().mapNotNull { chainAsset ->
syncAsset(chainAsset, scopeValue, accountId, storageSubscriptionBuilder)
}
.mergeIfMultiple()
.noSideAffects()
}
private suspend fun syncAsset(
chainAsset: Chain.Asset,
metaAccount: MetaAccount,
accountId: AccountId,
storageSubscriptionBuilder: SharedRequestsBuilder
): Flow<BalanceSyncUpdate>? {
val assetSource = assetSourceRegistry.sourceFor(chainAsset)
val assetUpdateFlow = runCatching {
assetSource.balance.startSyncingBalance(chain, chainAsset, metaAccount, accountId, storageSubscriptionBuilder)
}
.onFailure { logSyncError(chain, chainAsset, error = it) }
.getOrNull()
?: return null
return assetUpdateFlow.onEach { balanceUpdate ->
assetSource.history.syncOperationsForBalanceChange(chainAsset, balanceUpdate, accountId)
}
.catch { logSyncError(chain, chainAsset, error = it) }
}
private fun logSyncError(chain: Chain, chainAsset: Chain.Asset, error: Throwable) {
Log.e(LOG_TAG, "Failed to sync balance for ${chainAsset.symbol} in ${chain.name}", error)
}
private suspend fun AssetHistory.syncOperationsForBalanceChange(
chainAsset: Chain.Asset,
balanceSyncUpdate: BalanceSyncUpdate,
accountId: AccountId,
) {
when (balanceSyncUpdate) {
is BalanceSyncUpdate.CauseFetchable -> runCatching { fetchOperationsForBalanceChange(chain, chainAsset, balanceSyncUpdate.blockHash, accountId) }
.onSuccess { blockOperations ->
val localOperations = blockOperations
.filter { it.type.relates(accountId) }
.map { operation -> createOperationLocal(chainAsset, operation, accountId) }
operationDao.insertAll(localOperations)
}.onFailure {
Log.e(LOG_TAG, "Failed to retrieve transactions from block (${chain.name}.${chainAsset.symbol})", it)
}
is BalanceSyncUpdate.CauseFetched -> {
val local = createOperationLocal(chainAsset, balanceSyncUpdate.cause, accountId)
operationDao.insert(local)
}
BalanceSyncUpdate.NoCause -> {}
}
}
private suspend fun createOperationLocal(
chainAsset: Chain.Asset,
historyUpdate: RealtimeHistoryUpdate,
accountId: ByteArray,
): OperationLocal {
return when (val type = historyUpdate.type) {
is RealtimeHistoryUpdate.Type.Swap -> createSwapOperation(chainAsset, historyUpdate, type, accountId)
is RealtimeHistoryUpdate.Type.Transfer -> createTransferOperation(chainAsset, historyUpdate, type, accountId)
}
}
private fun createSwapOperation(
chainAsset: Chain.Asset,
historyUpdate: RealtimeHistoryUpdate,
swap: RealtimeHistoryUpdate.Type.Swap,
accountId: ByteArray,
): OperationLocal {
return OperationLocal.manualSwap(
hash = historyUpdate.txHash,
originAddress = chain.addressOf(accountId),
assetId = chainAsset.localId,
fee = mapAssetWithAmountToLocal(swap.amountFee),
amountIn = mapAssetWithAmountToLocal(swap.amountIn),
amountOut = mapAssetWithAmountToLocal(swap.amountOut),
status = mapOperationStatusToOperationLocalStatus(historyUpdate.status),
source = OperationBaseLocal.Source.BLOCKCHAIN
)
}
private suspend fun createTransferOperation(
chainAsset: Chain.Asset,
historyUpdate: RealtimeHistoryUpdate,
transfer: RealtimeHistoryUpdate.Type.Transfer,
accountId: ByteArray,
): OperationLocal {
val localStatus = mapOperationStatusToOperationLocalStatus(historyUpdate.status)
val address = chain.addressOf(accountId)
val localCopy = operationDao.getTransferType(historyUpdate.txHash, address, chain.id, chainAsset.id)
return OperationLocal.manualTransfer(
hash = historyUpdate.txHash,
chainId = chain.id,
address = address,
chainAssetId = chainAsset.id,
amount = transfer.amountInPlanks,
senderAddress = chain.addressOf(transfer.senderId),
receiverAddress = chain.addressOf(transfer.recipientId),
fee = localCopy?.fee,
status = localStatus,
source = OperationBaseLocal.Source.BLOCKCHAIN,
)
}
}
@@ -0,0 +1,64 @@
package io.novafoundation.nova.feature_wallet_impl.data.network.blockchain.updaters.balance
import android.util.Log
import io.novafoundation.nova.core.updater.SharedRequestsBuilder
import io.novafoundation.nova.core.updater.Updater
import io.novafoundation.nova.core_db.model.AssetLocal
import io.novafoundation.nova.feature_account_api.domain.model.MetaAccount
import io.novafoundation.nova.feature_account_api.domain.updaters.AccountUpdateScope
import io.novafoundation.nova.feature_wallet_api.data.cache.AssetCache
import io.novafoundation.nova.runtime.multiNetwork.ChainRegistry
import io.novafoundation.nova.runtime.multiNetwork.chain.model.Chain
import io.novasama.substrate_sdk_android.extensions.toHexString
import io.novasama.substrate_sdk_android.hash.Hasher.blake2b128Concat
import io.novasama.substrate_sdk_android.hash.Hasher.xxHash128
import io.novasama.substrate_sdk_android.runtime.AccountId
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.emptyFlow
import kotlinx.coroutines.flow.onEach
/**
* Runtime-independent updater that watches on-chain account presence and switches to full sync mode if account is present
*/
class LightSyncPaymentUpdater(
override val scope: AccountUpdateScope,
private val chainRegistry: ChainRegistry,
private val assetCache: AssetCache,
private val chain: Chain
) : Updater<MetaAccount> {
override val requiredModules: List<String> = emptyList()
override suspend fun listenForUpdates(storageSubscriptionBuilder: SharedRequestsBuilder, scopeValue: MetaAccount): Flow<Updater.SideEffect> {
val accountId = scopeValue.accountIdIn(chain) ?: return emptyFlow()
val storageKey = systemAccountStorageKey(accountId)
return storageSubscriptionBuilder.subscribe(storageKey).onEach { storageChange ->
if (storageChange.value != null) {
switchToFullSync()
Log.d("ConnectionState", "Detected balance during light sync for ${chain.name}, switching to full sync mode")
} else {
insertEmptyBalances(scopeValue)
}
}.noSideAffects()
}
private suspend fun insertEmptyBalances(metaAccount: MetaAccount) {
assetCache.updateAssetsByChain(metaAccount, chain) { chainAsset ->
AssetLocal.createEmpty(chainAsset.id, chainAsset.chainId, metaAccount.id)
}
}
private suspend fun switchToFullSync() {
chainRegistry.enableFullSync(chain.id)
}
private fun systemAccountStorageKey(accountId: AccountId): String {
val keyBytes = "System".xxHash128() + "Account".xxHash128() + accountId.blake2b128Concat()
return keyBytes.toHexString(withPrefix = true)
}
private fun String.xxHash128() = toByteArray().xxHash128()
}
@@ -0,0 +1,38 @@
package io.novafoundation.nova.feature_wallet_impl.data.network.blockchain.updaters.balance
import io.novafoundation.nova.core.updater.Updater
import io.novafoundation.nova.core_db.dao.OperationDao
import io.novafoundation.nova.feature_account_api.domain.model.MetaAccount
import io.novafoundation.nova.feature_account_api.domain.updaters.AccountUpdateScope
import io.novafoundation.nova.feature_wallet_api.data.cache.AssetCache
import io.novafoundation.nova.feature_wallet_api.data.network.blockhain.assets.AssetSourceRegistry
import io.novafoundation.nova.feature_wallet_api.data.network.blockhain.updaters.PaymentUpdaterFactory
import io.novafoundation.nova.runtime.multiNetwork.ChainRegistry
import io.novafoundation.nova.runtime.multiNetwork.chain.model.Chain
class RealPaymentUpdaterFactory(
private val operationDao: OperationDao,
private val assetSourceRegistry: AssetSourceRegistry,
private val scope: AccountUpdateScope,
private val chainRegistry: ChainRegistry,
private val assetCache: AssetCache
) : PaymentUpdaterFactory {
override fun createFullSync(chain: Chain): Updater<MetaAccount> {
return FullSyncPaymentUpdater(
operationDao = operationDao,
assetSourceRegistry = assetSourceRegistry,
scope = scope,
chain = chain,
)
}
override fun createLightSync(chain: Chain): Updater<MetaAccount> {
return LightSyncPaymentUpdater(
scope = scope,
chainRegistry = chainRegistry,
chain = chain,
assetCache = assetCache
)
}
}
@@ -0,0 +1,60 @@
package io.novafoundation.nova.feature_wallet_impl.data.network.blockchain.updaters.locks
import io.novafoundation.nova.core.updater.SharedRequestsBuilder
import io.novafoundation.nova.core.updater.Updater
import io.novafoundation.nova.feature_account_api.domain.model.MetaAccount
import io.novafoundation.nova.feature_account_api.domain.updaters.AccountUpdateScope
import io.novafoundation.nova.feature_wallet_api.data.network.blockhain.assets.AssetSourceRegistry
import io.novafoundation.nova.feature_wallet_api.data.network.blockhain.updaters.BalanceLocksUpdaterFactory
import io.novafoundation.nova.runtime.ext.enabledAssets
import io.novafoundation.nova.runtime.multiNetwork.chain.model.Chain
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.emptyFlow
import kotlinx.coroutines.flow.merge
class BalanceLocksUpdaterFactoryImpl(
private val scope: AccountUpdateScope,
private val assetSourceRegistry: AssetSourceRegistry,
) : BalanceLocksUpdaterFactory {
override fun create(chain: Chain): Updater<MetaAccount> {
return BalanceLocksUpdater(
scope,
assetSourceRegistry,
chain
)
}
}
class BalanceLocksUpdater(
override val scope: AccountUpdateScope,
private val assetSourceRegistry: AssetSourceRegistry,
private val chain: Chain
) : Updater<MetaAccount> {
override val requiredModules: List<String> = emptyList()
override suspend fun listenForUpdates(
storageSubscriptionBuilder: SharedRequestsBuilder,
scopeValue: MetaAccount,
): Flow<Updater.SideEffect> {
val metaAccount = scopeValue
val accountId = metaAccount.accountIdIn(chain) ?: return emptyFlow()
val flows = buildList {
chain.enabledAssets().forEach { chainAsset ->
val assetSource = assetSourceRegistry.sourceFor(chainAsset)
val locksFlow = assetSource.balance.startSyncingBalanceLocks(metaAccount, chain, chainAsset, accountId, storageSubscriptionBuilder)
val holdsFlow = assetSource.balance.startSyncingBalanceHolds(metaAccount, chain, chainAsset, accountId, storageSubscriptionBuilder)
add(locksFlow)
add(holdsFlow)
}
}
return flows
.merge()
.noSideAffects()
}
}
@@ -0,0 +1,3 @@
package io.novafoundation.nova.feature_wallet_impl.data.network.crosschain
typealias JunctionsRemote = Map<String, Any?>
@@ -0,0 +1,13 @@
package io.novafoundation.nova.feature_wallet_impl.data.network.crosschain
import io.novafoundation.nova.feature_wallet_impl.BuildConfig
import retrofit2.http.GET
interface CrossChainConfigApi {
@GET(BuildConfig.LEGACY_CROSS_CHAIN_CONFIG_URL)
suspend fun getLegacyCrossChainConfig(): String
@GET(BuildConfig.DYNAMIC_CROSS_CHAIN_CONFIG_URL)
suspend fun getDynamicCrossChainConfig(): String
}
@@ -0,0 +1,54 @@
package io.novafoundation.nova.feature_wallet_impl.data.network.crosschain
import io.novafoundation.nova.common.data.network.runtime.binding.Weight
import io.novafoundation.nova.common.utils.argument
import io.novafoundation.nova.common.utils.requireActualType
import io.novafoundation.nova.common.utils.structOf
import io.novafoundation.nova.common.utils.xcmPalletName
import io.novafoundation.nova.feature_wallet_api.data.network.blockhain.types.Balance
import io.novafoundation.nova.feature_xcm_api.message.VersionedXcmMessage
import io.novafoundation.nova.feature_xcm_api.versions.toEncodableInstance
import io.novasama.substrate_sdk_android.runtime.RuntimeSnapshot
import io.novasama.substrate_sdk_android.runtime.definitions.types.Type
import io.novasama.substrate_sdk_android.runtime.definitions.types.composite.Struct
import io.novasama.substrate_sdk_android.runtime.definitions.types.primitives.NumberType
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.metadata.call
import io.novasama.substrate_sdk_android.runtime.metadata.module
fun ExtrinsicBuilder.xcmExecute(
message: VersionedXcmMessage,
maxWeight: Weight,
): ExtrinsicBuilder {
return call(
moduleName = runtime.metadata.xcmPalletName(),
callName = "execute",
arguments = mapOf(
"message" to message.toEncodableInstance(),
"max_weight" to runtime.prepareWeightForEncoding(maxWeight)
)
)
}
private fun RuntimeSnapshot.prepareWeightForEncoding(weight: Weight): Any {
val moduleName = metadata.xcmPalletName()
val weightArgumentType = metadata.module(moduleName)
.call("execute")
.argument("max_weight")
.requireActualType()
return when {
weightArgumentType.isWeightV1() -> weight
else -> weight.encodeWeightV2()
}
}
private fun Weight.encodeWeightV2(): Struct.Instance {
return structOf("refTime" to this, "proofSize" to Balance.ZERO)
}
private fun Type<*>.isWeightV1(): Boolean {
return this is NumberType
}
@@ -0,0 +1,241 @@
package io.novafoundation.nova.feature_wallet_impl.data.network.crosschain
import android.util.Log
import io.novafoundation.nova.common.utils.Modules
import io.novafoundation.nova.common.utils.flatMap
import io.novafoundation.nova.common.utils.instanceOf
import io.novafoundation.nova.common.utils.transformResult
import io.novafoundation.nova.common.utils.wrapInResult
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.ExtrinsicSubmission
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.model.Fee
import io.novafoundation.nova.feature_wallet_api.data.network.blockhain.assets.AssetSourceRegistry
import io.novafoundation.nova.feature_wallet_api.data.network.blockhain.assets.balances.model.TransferableBalanceUpdatePoint
import io.novafoundation.nova.feature_wallet_api.data.network.blockhain.assets.events.tryDetectDeposit
import io.novafoundation.nova.feature_wallet_api.data.network.blockhain.assets.tranfers.AssetTransferBase
import io.novafoundation.nova.feature_wallet_api.data.network.blockhain.types.Balance
import io.novafoundation.nova.feature_wallet_api.data.network.crosschain.CrossChainTransactor
import io.novafoundation.nova.feature_wallet_api.domain.model.xcm.CrossChainTransferConfiguration
import io.novafoundation.nova.feature_wallet_api.domain.model.xcm.destinationChainId
import io.novafoundation.nova.feature_wallet_api.domain.model.xcm.dynamic.DynamicCrossChainTransferFeatures
import io.novafoundation.nova.feature_wallet_api.domain.model.xcm.dynamic.reserve.remoteReserveLocation
import io.novafoundation.nova.feature_wallet_api.domain.model.xcm.originChainId
import io.novafoundation.nova.feature_wallet_impl.data.network.crosschain.dynamic.DynamicCrossChainTransactor
import io.novafoundation.nova.feature_wallet_impl.data.network.crosschain.legacy.LegacyCrossChainTransactor
import io.novafoundation.nova.runtime.multiNetwork.ChainRegistry
import io.novafoundation.nova.runtime.multiNetwork.chain.model.ChainId
import io.novafoundation.nova.runtime.multiNetwork.findRelayChainOrThrow
import io.novafoundation.nova.runtime.multiNetwork.runtime.repository.BlockEvents
import io.novafoundation.nova.runtime.multiNetwork.runtime.repository.EventsRepository
import io.novafoundation.nova.runtime.multiNetwork.runtime.repository.hasEvent
import io.novafoundation.nova.runtime.repository.ChainStateRepository
import io.novafoundation.nova.runtime.repository.expectedBlockTime
import io.novasama.substrate_sdk_android.runtime.definitions.types.generics.GenericEvent
import io.novasama.substrate_sdk_android.runtime.extrinsic.builder.ExtrinsicBuilder
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.SharingStarted
import kotlinx.coroutines.flow.first
import kotlinx.coroutines.flow.shareIn
import kotlinx.coroutines.withTimeout
import kotlin.coroutines.coroutineContext
import kotlin.time.Duration
import kotlin.time.Duration.Companion.ZERO
import kotlin.time.Duration.Companion.seconds
class RealCrossChainTransactor(
private val assetSourceRegistry: AssetSourceRegistry,
private val eventsRepository: EventsRepository,
private val chainStateRepository: ChainStateRepository,
private val chainRegistry: ChainRegistry,
private val dynamic: DynamicCrossChainTransactor,
private val legacy: LegacyCrossChainTransactor,
) : CrossChainTransactor {
context(ExtrinsicService)
override suspend fun estimateOriginFee(
configuration: CrossChainTransferConfiguration,
transfer: AssetTransferBase
): Fee {
return estimateFee(
chain = transfer.originChain,
origin = TransactionOrigin.SelectedWallet,
submissionOptions = ExtrinsicService.SubmissionOptions(
feePaymentCurrency = transfer.feePaymentCurrency
)
) {
crossChainTransfer(configuration, transfer, crossChainFee = Balance.ZERO)
}
}
context(ExtrinsicService)
override suspend fun performTransfer(
configuration: CrossChainTransferConfiguration,
transfer: AssetTransferBase,
crossChainFee: Balance
): Result<ExtrinsicSubmission> {
return submitExtrinsic(
chain = transfer.originChain,
origin = TransactionOrigin.SelectedWallet,
submissionOptions = ExtrinsicService.SubmissionOptions(
feePaymentCurrency = transfer.feePaymentCurrency
)
) {
crossChainTransfer(configuration, transfer, crossChainFee)
}
}
override suspend fun requiredRemainingAmountAfterTransfer(configuration: CrossChainTransferConfiguration): Balance {
return when (configuration) {
is CrossChainTransferConfiguration.Dynamic -> dynamic.requiredRemainingAmountAfterTransfer(configuration.config)
is CrossChainTransferConfiguration.Legacy -> legacy.requiredRemainingAmountAfterTransfer(configuration.config)
}
}
context(ExtrinsicService)
override suspend fun performAndTrackTransfer(
configuration: CrossChainTransferConfiguration,
transfer: AssetTransferBase,
): Result<Balance> {
// Start balances updates eagerly to not to miss events in case tx has been included to block right after submission
val balancesUpdates = observeTransferableBalance(transfer)
.wrapInResult()
.shareIn(CoroutineScope(coroutineContext), SharingStarted.Eagerly, replay = 100)
Log.d("CrossChain", "Starting cross-chain transfer")
return performTransferOfExactAmount(configuration, transfer)
.requireOk()
.flatMap {
Log.d("CrossChain", "Cross chain transfer for successfully executed on origin, waiting for destination")
balancesUpdates.awaitCrossChainArrival(transfer)
}
}
override suspend fun supportsXcmExecute(
originChainId: ChainId,
features: DynamicCrossChainTransferFeatures
): Boolean {
return dynamic.supportsXcmExecute(originChainId, features)
}
override suspend fun estimateMaximumExecutionTime(configuration: CrossChainTransferConfiguration): Duration {
val originChainId = configuration.originChainId
val remoteReserveChainId = configuration.transferType.remoteReserveLocation()?.chainId
val destinationChainId = configuration.destinationChainId
val relayId = chainRegistry.findRelayChainOrThrow(originChainId)
var totalDuration = ZERO
if (remoteReserveChainId != null) {
totalDuration += maxTimeToTransmitMessage(originChainId, remoteReserveChainId, relayId)
totalDuration += maxTimeToTransmitMessage(remoteReserveChainId, destinationChainId, relayId)
} else {
totalDuration += maxTimeToTransmitMessage(originChainId, destinationChainId, relayId)
}
return totalDuration
}
private suspend fun maxTimeToTransmitMessage(from: ChainId, to: ChainId, relay: ChainId): Duration {
val toProduceBlockOnOrigin = chainStateRepository.expectedBlockTime(from)
val toProduceBlockOnDestination = chainStateRepository.expectedBlockTime(to)
val toProduceBlockOnRelay = if (from != relay && to != relay) chainStateRepository.expectedBlockTime(relay) else ZERO
return toProduceBlockOnOrigin + toProduceBlockOnRelay + toProduceBlockOnDestination
}
private suspend fun Flow<Result<TransferableBalanceUpdatePoint>>.awaitCrossChainArrival(transfer: AssetTransferBase): Result<Balance> {
return runCatching {
withTimeout(60.seconds) {
transformResult { balanceUpdate ->
Log.d("CrossChain", "Destination balance update detected: $balanceUpdate")
val updatedAt = balanceUpdate.updatedAt
val blockEvents = eventsRepository.getBlockEvents(transfer.destinationChain.id, updatedAt)
val xcmArrivedDeposit = searchForXcmArrival(blockEvents.initialization, transfer)
?: searchForXcmArrival(blockEvents.finalization, transfer)
?: searchForXcmArrival(blockEvents.findSetValidationDataEvents(), transfer)
if (xcmArrivedDeposit != null) {
Log.d("CrossChain", "Found destination xcm arrival event, amount is $xcmArrivedDeposit")
emit(xcmArrivedDeposit)
} else {
Log.d("CrossChain", "No destination xcm arrival event found for the received balance update")
}
}
.first()
.getOrThrow()
}
}
}
private fun BlockEvents.findSetValidationDataEvents(): List<GenericEvent.Instance> {
val setValidationDataExtrinsic = applyExtrinsic.find { it.extrinsic.call.instanceOf(Modules.PARACHAIN_SYSTEM, "set_validation_data") }
return setValidationDataExtrinsic?.events.orEmpty()
}
private suspend fun searchForXcmArrival(
events: List<GenericEvent.Instance>,
transfer: AssetTransferBase
): Balance? {
if (!events.hasXcmArrivalEvent()) return null
val eventDetector = assetSourceRegistry.getEventDetector(transfer.destinationChainAsset)
val depositEvent = events.mapNotNull { event -> eventDetector.tryDetectDeposit(event) }
.find { it.destination == transfer.recipientAccountId }
return depositEvent?.amount
}
private fun List<GenericEvent.Instance>.hasXcmArrivalEvent(): Boolean {
return hasEvent("MessageQueue", "Processed") or hasEvent("XcmpQueue", "Success")
}
private suspend fun ExtrinsicService.performTransferOfExactAmount(
configuration: CrossChainTransferConfiguration,
transfer: AssetTransferBase,
): Result<ExtrinsicExecutionResult> {
return submitExtrinsicAndAwaitExecution(
chain = transfer.originChain,
origin = TransactionOrigin.SelectedWallet,
submissionOptions = ExtrinsicService.SubmissionOptions(
feePaymentCurrency = transfer.feePaymentCurrency
)
) {
// We are transferring the exact amount, so we should add nothing on top of the transfer amount
crossChainTransfer(configuration, transfer, crossChainFee = Balance.ZERO)
}
}
private suspend fun observeTransferableBalance(transfer: AssetTransferBase): Flow<TransferableBalanceUpdatePoint> {
val destinationAssetBalances = assetSourceRegistry.sourceFor(transfer.destinationChainAsset)
return destinationAssetBalances.balance.subscribeAccountBalanceUpdatePoint(
chain = transfer.destinationChain,
chainAsset = transfer.destinationChainAsset,
accountId = transfer.recipientAccountId.value,
)
}
private suspend fun ExtrinsicBuilder.crossChainTransfer(
configuration: CrossChainTransferConfiguration,
transfer: AssetTransferBase,
crossChainFee: Balance
) {
when (configuration) {
is CrossChainTransferConfiguration.Dynamic -> dynamic.crossChainTransfer(configuration.config, transfer, crossChainFee)
is CrossChainTransferConfiguration.Legacy -> legacy.crossChainTransfer(configuration.config, transfer, crossChainFee)
}
}
}
@@ -0,0 +1,21 @@
package io.novafoundation.nova.feature_wallet_impl.data.network.crosschain
import io.novafoundation.nova.feature_wallet_api.data.network.blockhain.assets.tranfers.AssetTransferBase
import io.novafoundation.nova.feature_wallet_api.data.network.crosschain.CrossChainFeeModel
import io.novafoundation.nova.feature_wallet_api.data.network.crosschain.CrossChainWeigher
import io.novafoundation.nova.feature_wallet_api.domain.model.xcm.CrossChainTransferConfiguration
import io.novafoundation.nova.feature_wallet_impl.data.network.crosschain.dynamic.DynamicCrossChainWeigher
import io.novafoundation.nova.feature_wallet_impl.data.network.crosschain.legacy.LegacyCrossChainWeigher
class RealCrossChainWeigher(
private val dynamic: DynamicCrossChainWeigher,
private val legacy: LegacyCrossChainWeigher
) : CrossChainWeigher {
override suspend fun estimateFee(transfer: AssetTransferBase, config: CrossChainTransferConfiguration): CrossChainFeeModel {
return when (config) {
is CrossChainTransferConfiguration.Dynamic -> dynamic.estimateFee(config.config, transfer)
is CrossChainTransferConfiguration.Legacy -> legacy.estimateFee(transfer.amountPlanks, config.config)
}
}
}
@@ -0,0 +1,118 @@
package io.novafoundation.nova.feature_wallet_impl.data.network.crosschain.common
import android.util.Log
import io.novafoundation.nova.common.di.scope.FeatureScope
import io.novafoundation.nova.common.utils.composeCall
import io.novafoundation.nova.common.utils.metadata
import io.novafoundation.nova.common.utils.xcmPalletName
import io.novafoundation.nova.feature_wallet_api.data.network.blockhain.assets.tranfers.AssetTransferBase
import io.novafoundation.nova.feature_wallet_api.data.network.blockhain.types.Balance
import io.novafoundation.nova.feature_wallet_api.domain.model.xcm.CrossChainTransferConfigurationBase
import io.novafoundation.nova.feature_wallet_api.domain.model.xcm.assetLocationOnOrigin
import io.novafoundation.nova.feature_wallet_api.domain.model.xcm.destinationChainLocationOnOrigin
import io.novafoundation.nova.feature_wallet_api.domain.model.xcm.dynamic.reserve.XcmTransferType
import io.novafoundation.nova.feature_wallet_api.domain.model.xcm.originChainId
import io.novafoundation.nova.feature_wallet_api.domain.model.xcm.originChainLocation
import io.novafoundation.nova.feature_xcm_api.asset.MultiAsset
import io.novafoundation.nova.feature_xcm_api.asset.MultiAssetFilter
import io.novafoundation.nova.feature_xcm_api.asset.MultiAssetId
import io.novafoundation.nova.feature_xcm_api.asset.MultiAssets
import io.novafoundation.nova.feature_xcm_api.builder.XcmBuilder
import io.novafoundation.nova.feature_xcm_api.builder.buildXcmWithoutFeesMeasurement
import io.novafoundation.nova.feature_xcm_api.message.VersionedXcmMessage
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.feature_xcm_api.versions.toEncodableInstance
import io.novafoundation.nova.feature_xcm_api.versions.versionedXcm
import io.novafoundation.nova.feature_xcm_api.weight.WeightLimit
import io.novafoundation.nova.runtime.multiNetwork.ChainRegistry
import io.novafoundation.nova.runtime.multiNetwork.withRuntime
import io.novasama.substrate_sdk_android.runtime.definitions.types.composite.DictEnum
import io.novasama.substrate_sdk_android.runtime.definitions.types.generics.GenericCall
import javax.inject.Inject
@FeatureScope
class TransferAssetUsingTypeTransactor @Inject constructor(
private val chainRegistry: ChainRegistry,
private val xcmBuilderFactory: XcmBuilder.Factory,
private val xcmVersionDetector: XcmVersionDetector,
) {
suspend fun composeCall(
configuration: CrossChainTransferConfigurationBase,
transfer: AssetTransferBase,
crossChainFee: Balance,
forceXcmVersion: XcmVersion? = null
): GenericCall.Instance {
val totalTransferAmount = transfer.amountPlanks + crossChainFee
val multiAsset = MultiAsset.from(configuration.assetLocationOnOrigin(), totalTransferAmount)
val multiAssetId = MultiAssetId(configuration.assetLocationOnOrigin())
val multiLocationVersion = forceXcmVersion ?: xcmVersionDetector.lowestPresentMultiLocationVersion(transfer.originChain.id).orDefault()
val multiAssetsVersion = forceXcmVersion ?: xcmVersionDetector.lowestPresentMultiAssetsVersion(transfer.originChain.id).orDefault()
val multiAssetIdVersion = forceXcmVersion ?: xcmVersionDetector.lowestPresentMultiAssetIdVersion(transfer.originChain.id).orDefault()
val transferTypeParam = configuration.transferTypeParam(multiAssetsVersion)
// Debug logging for XCM transfer
val destLocation = configuration.destinationChainLocationOnOrigin()
Log.d("XCM_TRANSFER", "=== XCM TRANSFER DEBUG ===")
Log.d("XCM_TRANSFER", "Origin chain: ${configuration.originChain.chain.name} (${configuration.originChain.chain.id})")
Log.d("XCM_TRANSFER", "Origin parachainId: ${configuration.originChain.parachainId}")
Log.d("XCM_TRANSFER", "Destination chain: ${configuration.destinationChain.chain.name} (${configuration.destinationChain.chain.id})")
Log.d("XCM_TRANSFER", "Destination parachainId: ${configuration.destinationChain.parachainId}")
Log.d("XCM_TRANSFER", "Destination location (relative): parents=${destLocation.parents}, interior=${destLocation.interior}")
Log.d("XCM_TRANSFER", "Destination junctions: ${destLocation.interior}")
Log.d("XCM_TRANSFER", "Transfer type: ${configuration.transferType}")
Log.d("XCM_TRANSFER", "XCM Version: $multiLocationVersion")
Log.d("XCM_TRANSFER", "==========================")
return chainRegistry.withRuntime(configuration.originChainId) {
composeCall(
moduleName = metadata.xcmPalletName(),
callName = "transfer_assets_using_type_and_then",
arguments = mapOf(
"dest" to configuration.destinationChainLocationOnOrigin().versionedXcm(multiLocationVersion).toEncodableInstance(),
"assets" to MultiAssets(multiAsset).versionedXcm(multiAssetsVersion).toEncodableInstance(),
"assets_transfer_type" to transferTypeParam,
"remote_fees_id" to multiAssetId.versionedXcm(multiAssetIdVersion).toEncodableInstance(),
"fees_transfer_type" to transferTypeParam,
"custom_xcm_on_dest" to constructCustomXcmOnDest(configuration, transfer, multiLocationVersion).toEncodableInstance(),
"weight_limit" to WeightLimit.Unlimited.toEncodableInstance()
)
)
}
}
private fun CrossChainTransferConfigurationBase.transferTypeParam(locationXcmVersion: XcmVersion): Any {
return when (val type = transferType) {
is XcmTransferType.Teleport -> DictEnum.Entry("Teleport", null)
is XcmTransferType.Reserve.Destination -> DictEnum.Entry("DestinationReserve", null)
is XcmTransferType.Reserve.Origin -> DictEnum.Entry("LocalReserve", null)
is XcmTransferType.Reserve.Remote -> {
val reserveChainRelative = type.remoteReserveLocation.location.fromPointOfViewOf(originChainLocation.location)
val remoteReserveEncodable = reserveChainRelative.versionedXcm(locationXcmVersion).toEncodableInstance()
DictEnum.Entry("RemoteReserve", remoteReserveEncodable)
}
}
}
private suspend fun constructCustomXcmOnDest(
configuration: CrossChainTransferConfigurationBase,
transfer: AssetTransferBase,
minDetectedXcmVersion: XcmVersion
): VersionedXcmMessage {
return xcmBuilderFactory.buildXcmWithoutFeesMeasurement(
initial = configuration.originChainLocation,
// singleCounted is only available from V3
xcmVersion = minDetectedXcmVersion.coerceAtLeast(XcmVersion.V3)
) {
depositAsset(MultiAssetFilter.singleCounted(), transfer.recipientAccountId)
}
}
}
@@ -0,0 +1,65 @@
package io.novafoundation.nova.feature_wallet_impl.data.network.crosschain.dynamic
import io.novafoundation.nova.feature_wallet_impl.data.network.crosschain.JunctionsRemote
import io.novafoundation.nova.runtime.multiNetwork.chain.model.ChainId
class DynamicCrossChainTransfersConfigRemote(
val assetsLocation: Map<String, DynamicReserveLocationRemote>?,
// (ChainId, AssetId) -> ReserveId
val reserveIdOverrides: Map<String, Map<Int, String>>,
val chains: List<DynamicCrossChainOriginChainRemote>?,
val customTeleports: List<CustomTeleportEntryRemote>?,
)
class CustomTeleportEntryRemote(
val originChain: String,
val destChain: String,
val originAsset: Int
)
class DynamicReserveLocationRemote(
val chainId: ChainId,
val multiLocation: JunctionsRemote
)
class DynamicCrossChainOriginChainRemote(
val chainId: ChainId,
val assets: List<DynamicCrossChainOriginAssetRemote>
)
class DynamicCrossChainOriginAssetRemote(
val assetId: Int,
val xcmTransfers: List<DynamicXcmTransferRemote>,
)
class DynamicXcmTransferRemote(
// New format: nested destination object
val destination: XcmTransferDestinationRemote?,
// Legacy format: chainId and assetId at root level
val chainId: ChainId?,
val assetId: Int?,
val type: String?,
val hasDeliveryFee: Boolean?,
val supportsXcmExecute: Boolean?,
) {
/**
* Get the destination chainId, supporting both new and legacy formats.
*/
fun getDestinationChainId(): ChainId {
return destination?.chainId ?: chainId
?: throw IllegalStateException("XCM transfer has no destination chainId")
}
/**
* Get the destination assetId, supporting both new and legacy formats.
*/
fun getDestinationAssetId(): Int {
return destination?.assetId ?: assetId
?: throw IllegalStateException("XCM transfer has no destination assetId")
}
}
class XcmTransferDestinationRemote(
val chainId: ChainId,
val assetId: Int,
)
@@ -0,0 +1,263 @@
package io.novafoundation.nova.feature_wallet_impl.data.network.crosschain.dynamic
import android.util.Log
import io.novafoundation.nova.common.address.AccountIdKey
import io.novafoundation.nova.common.data.network.runtime.binding.WeightV2
import io.novafoundation.nova.common.di.scope.FeatureScope
import io.novafoundation.nova.feature_wallet_api.data.network.blockhain.assets.AssetSourceRegistry
import io.novafoundation.nova.feature_wallet_api.data.network.blockhain.assets.tranfers.AssetTransferBase
import io.novafoundation.nova.feature_wallet_api.data.network.blockhain.types.Balance
import io.novafoundation.nova.feature_wallet_api.domain.model.xcm.destinationChainLocation
import io.novafoundation.nova.feature_wallet_api.domain.model.xcm.dynamic.DynamicCrossChainTransferConfiguration
import io.novafoundation.nova.feature_wallet_api.domain.model.xcm.dynamic.DynamicCrossChainTransferFeatures
import io.novafoundation.nova.feature_wallet_api.domain.model.xcm.dynamic.reserve.XcmTransferType
import io.novafoundation.nova.feature_wallet_api.domain.model.xcm.originChainId
import io.novafoundation.nova.feature_wallet_api.domain.model.xcm.originChainLocation
import io.novafoundation.nova.feature_wallet_impl.data.network.crosschain.common.TransferAssetUsingTypeTransactor
import io.novafoundation.nova.feature_xcm_api.asset.MultiAssetFilter
import io.novafoundation.nova.feature_xcm_api.builder.XcmBuilder
import io.novafoundation.nova.feature_xcm_api.builder.buyExecution
import io.novafoundation.nova.feature_xcm_api.builder.createWithoutFeesMeasurement
import io.novafoundation.nova.feature_xcm_api.builder.withdrawAsset
import io.novafoundation.nova.feature_xcm_api.extrinsic.composeXcmExecute
import io.novafoundation.nova.feature_xcm_api.message.VersionedXcmMessage
import io.novafoundation.nova.feature_xcm_api.multiLocation.AbsoluteMultiLocation
import io.novafoundation.nova.feature_xcm_api.multiLocation.ChainLocation
import io.novafoundation.nova.feature_xcm_api.runtimeApi.getInnerSuccessOrThrow
import io.novafoundation.nova.feature_xcm_api.runtimeApi.xcmPayment.XcmPaymentApi
import io.novafoundation.nova.feature_xcm_api.versions.XcmVersion
import io.novafoundation.nova.feature_xcm_api.weight.WeightLimit
import io.novafoundation.nova.runtime.multiNetwork.ChainRegistry
import io.novafoundation.nova.runtime.multiNetwork.chain.model.ChainId
import io.novafoundation.nova.runtime.multiNetwork.withRuntime
import io.novasama.substrate_sdk_android.runtime.definitions.types.generics.GenericCall
import io.novasama.substrate_sdk_android.runtime.extrinsic.builder.ExtrinsicBuilder
import java.math.BigInteger
import javax.inject.Inject
private val USED_XCM_VERSION = XcmVersion.V4
@FeatureScope
class DynamicCrossChainTransactor @Inject constructor(
private val chainRegistry: ChainRegistry,
private val xcmBuilderFactory: XcmBuilder.Factory,
private val xcmPaymentApi: XcmPaymentApi,
private val assetSourceRegistry: AssetSourceRegistry,
private val usingTypeTransactor: TransferAssetUsingTypeTransactor,
) {
context(ExtrinsicBuilder)
suspend fun crossChainTransfer(
configuration: DynamicCrossChainTransferConfiguration,
transfer: AssetTransferBase,
crossChainFee: Balance
) {
val call = composeCrossChainTransferCall(configuration, transfer, crossChainFee)
call(call)
}
suspend fun requiredRemainingAmountAfterTransfer(
configuration: DynamicCrossChainTransferConfiguration
): Balance {
return if (supportsXcmExecute(configuration)) {
BigInteger.ZERO
} else {
val chainAsset = configuration.originChainAsset
assetSourceRegistry.sourceFor(chainAsset).balance.existentialDeposit(chainAsset)
}
}
suspend fun composeCrossChainTransferCall(
configuration: DynamicCrossChainTransferConfiguration,
transfer: AssetTransferBase,
crossChainFee: Balance
): GenericCall.Instance {
return if (supportsXcmExecute(configuration)) {
composeXcmExecuteCall(configuration, transfer, crossChainFee)
} else {
usingTypeTransactor.composeCall(configuration, transfer, crossChainFee, forceXcmVersion = USED_XCM_VERSION)
}
}
suspend fun supportsXcmExecute(originChainId: ChainId, features: DynamicCrossChainTransferFeatures): Boolean {
val supportsXcmExecute = features.supportsXcmExecute
val hasXcmPaymentApi = xcmPaymentApi.isSupported(originChainId)
// For now, only enable xcm execute approach for the directions that will hugely benefit from it
// In particular, xcm execute allows us to pay delivery fee from the holding register and not in JIT mode (from account)
val hasDeliveryFee = features.hasDeliveryFee
return supportsXcmExecute && hasXcmPaymentApi && hasDeliveryFee
}
private suspend fun supportsXcmExecute(configuration: DynamicCrossChainTransferConfiguration): Boolean {
return supportsXcmExecute(configuration.originChainId, configuration.features)
}
private suspend fun composeXcmExecuteCall(
configuration: DynamicCrossChainTransferConfiguration,
transfer: AssetTransferBase,
crossChainFee: Balance
): GenericCall.Instance {
val xcmProgram = buildXcmProgram(configuration, transfer, crossChainFee)
val weight = xcmPaymentApi.queryXcmWeight(configuration.originChainId, xcmProgram)
.getInnerSuccessOrThrow("DynamicCrossChainTransactor")
return chainRegistry.withRuntime(configuration.originChainId) {
composeXcmExecute(xcmProgram, weight)
}
}
private suspend fun buildXcmProgram(
configuration: DynamicCrossChainTransferConfiguration,
transfer: AssetTransferBase,
crossChainFee: Balance
): VersionedXcmMessage {
val builder = xcmBuilderFactory.createWithoutFeesMeasurement(
initial = configuration.originChainLocation,
xcmVersion = USED_XCM_VERSION
)
builder.buildTransferProgram(configuration, transfer, crossChainFee)
return builder.build()
}
private fun XcmBuilder.buildTransferProgram(
configuration: DynamicCrossChainTransferConfiguration,
transfer: AssetTransferBase,
crossChainFee: Balance
) {
val totalTransferAmount = transfer.amountPlanks + crossChainFee
val assetAbsoluteMultiLocation = configuration.transferType.assetAbsoluteLocation
// Debug logging for Dynamic XCM transfer
Log.d("XCM_DYNAMIC", "=== DYNAMIC XCM TRANSFER DEBUG ===")
Log.d("XCM_DYNAMIC", "Origin chain: ${configuration.originChain.chain.name} (${configuration.originChain.chain.id})")
Log.d("XCM_DYNAMIC", "Origin parachainId: ${configuration.originChain.parachainId}")
Log.d("XCM_DYNAMIC", "Destination chain: ${configuration.destinationChain.chain.name} (${configuration.destinationChain.chain.id})")
Log.d("XCM_DYNAMIC", "Destination parachainId: ${configuration.destinationChain.parachainId}")
Log.d("XCM_DYNAMIC", "Destination location: ${configuration.destinationChainLocation}")
Log.d("XCM_DYNAMIC", "Transfer type: ${configuration.transferType}")
Log.d("XCM_DYNAMIC", "================================")
when (val transferType = configuration.transferType) {
is XcmTransferType.Teleport -> buildTeleportProgram(
assetLocation = assetAbsoluteMultiLocation,
destinationChainLocation = configuration.destinationChainLocation,
beneficiary = transfer.recipientAccountId,
amount = totalTransferAmount
)
is XcmTransferType.Reserve.Origin -> buildOriginReserveProgram(
assetLocation = assetAbsoluteMultiLocation,
destinationChainLocation = configuration.destinationChainLocation,
beneficiary = transfer.recipientAccountId,
amount = totalTransferAmount
)
is XcmTransferType.Reserve.Destination -> buildDestinationReserveProgram(
assetLocation = assetAbsoluteMultiLocation,
destinationChainLocation = configuration.destinationChainLocation,
beneficiary = transfer.recipientAccountId,
amount = totalTransferAmount
)
is XcmTransferType.Reserve.Remote -> buildRemoteReserveProgram(
assetLocation = assetAbsoluteMultiLocation,
remoteReserveLocation = transferType.remoteReserveLocation,
destinationChainLocation = configuration.destinationChainLocation,
beneficiary = transfer.recipientAccountId,
amount = totalTransferAmount
)
}
}
private fun XcmBuilder.buildTeleportProgram(
assetLocation: AbsoluteMultiLocation,
destinationChainLocation: ChainLocation,
beneficiary: AccountIdKey,
amount: Balance,
) {
val feesAmount = deriveBuyExecutionUpperBoundAmount(amount)
// Origin
withdrawAsset(assetLocation, amount)
// Here and onward: we use buy execution for the very first segment to be able to pay delivery fees in sending asset
// WeightLimit.one() is used since it doesn't matter anyways as the message on origin is already weighted
// The only restriction is that it cannot be zero or Unlimited
buyExecution(assetLocation, feesAmount, WeightLimit.one())
initiateTeleport(MultiAssetFilter.singleCounted(), destinationChainLocation)
// Destination
buyExecution(assetLocation, feesAmount, WeightLimit.Unlimited)
depositAsset(MultiAssetFilter.singleCounted(), beneficiary)
}
private fun XcmBuilder.buildOriginReserveProgram(
assetLocation: AbsoluteMultiLocation,
destinationChainLocation: ChainLocation,
beneficiary: AccountIdKey,
amount: Balance,
) {
val feesAmount = deriveBuyExecutionUpperBoundAmount(amount)
// Origin
withdrawAsset(assetLocation, amount)
buyExecution(assetLocation, feesAmount, WeightLimit.one())
depositReserveAsset(MultiAssetFilter.singleCounted(), destinationChainLocation)
// Destination
buyExecution(assetLocation, feesAmount, WeightLimit.Unlimited)
depositAsset(MultiAssetFilter.singleCounted(), beneficiary)
}
private fun XcmBuilder.buildDestinationReserveProgram(
assetLocation: AbsoluteMultiLocation,
destinationChainLocation: ChainLocation,
beneficiary: AccountIdKey,
amount: Balance,
) {
val feesAmount = deriveBuyExecutionUpperBoundAmount(amount)
// Origin
withdrawAsset(assetLocation, amount)
buyExecution(assetLocation, feesAmount, WeightLimit.one())
initiateReserveWithdraw(MultiAssetFilter.singleCounted(), destinationChainLocation)
// Destination
buyExecution(assetLocation, feesAmount, WeightLimit.Unlimited)
depositAsset(MultiAssetFilter.singleCounted(), beneficiary)
}
private fun XcmBuilder.buildRemoteReserveProgram(
assetLocation: AbsoluteMultiLocation,
remoteReserveLocation: ChainLocation,
destinationChainLocation: ChainLocation,
beneficiary: AccountIdKey,
amount: Balance,
) {
val feesAmount = deriveBuyExecutionUpperBoundAmount(amount)
// Origin
withdrawAsset(assetLocation, amount)
buyExecution(assetLocation, feesAmount, WeightLimit.one())
initiateReserveWithdraw(MultiAssetFilter.singleCounted(), remoteReserveLocation)
// Remote reserve
buyExecution(assetLocation, feesAmount, WeightLimit.Unlimited)
depositReserveAsset(MultiAssetFilter.singleCounted(), destinationChainLocation)
// Destination
buyExecution(assetLocation, feesAmount, WeightLimit.Unlimited)
depositAsset(MultiAssetFilter.singleCounted(), beneficiary)
}
private fun deriveBuyExecutionUpperBoundAmount(transferringAmount: Balance): Balance {
return transferringAmount / 2.toBigInteger()
}
private fun WeightLimit.Companion.one(): WeightLimit.Limited {
return WeightLimit.Limited(WeightV2(1.toBigInteger(), 1.toBigInteger()))
}
}
@@ -0,0 +1,84 @@
package io.novafoundation.nova.feature_wallet_impl.data.network.crosschain.dynamic
import android.util.Log
import io.novafoundation.nova.common.di.scope.FeatureScope
import io.novafoundation.nova.common.utils.LOG_TAG
import io.novafoundation.nova.common.utils.orZero
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.replaceAmount
import io.novafoundation.nova.feature_wallet_api.data.network.blockhain.types.Balance
import io.novafoundation.nova.feature_wallet_api.data.network.crosschain.CrossChainFeeModel
import io.novafoundation.nova.feature_wallet_api.domain.model.planksFromAmount
import io.novafoundation.nova.feature_wallet_api.domain.model.xcm.dynamic.DynamicCrossChainTransferConfiguration
import io.novafoundation.nova.feature_wallet_api.data.network.crosschain.XcmTransferDryRunOrigin
import io.novafoundation.nova.feature_wallet_impl.data.network.crosschain.dynamic.dryRun.XcmTransferDryRunResult
import io.novafoundation.nova.feature_wallet_impl.data.network.crosschain.dynamic.dryRun.XcmTransferDryRunner
import javax.inject.Inject
private const val MINIMUM_SEND_AMOUNT = 100
@FeatureScope
class DynamicCrossChainWeigher @Inject constructor(
private val xcmTransferDryRunner: XcmTransferDryRunner,
) {
suspend fun estimateFee(
config: DynamicCrossChainTransferConfiguration,
transfer: AssetTransferBase
): CrossChainFeeModel {
val safeTransfer = transfer.ensureSafeAmount()
val result = xcmTransferDryRunner.dryRunXcmTransfer(config, safeTransfer, XcmTransferDryRunOrigin.Fake)
return result.getOrNull()?.let { dryRunResult ->
CrossChainFeeModel.fromDryRunResult(
initialAmount = safeTransfer.amountPlanks,
transferDryRunResult = dryRunResult
)
} ?: run {
// Dry run failed - use fallback fee estimation
// For teleport transfers, dry run often doesn't produce forwarded XCMs
Log.w(LOG_TAG, "Dry run failed for ${config.transferType}, using fallback fee estimation")
estimateFallbackFee(config, transfer)
}
}
/**
* Fallback fee estimation when dry run fails.
* Uses a conservative percentage of the transfer amount as fee buffer.
*/
private fun estimateFallbackFee(
config: DynamicCrossChainTransferConfiguration,
transfer: AssetTransferBase
): CrossChainFeeModel {
// Use 1% of transfer amount as conservative fee estimate for all transfer types
// This covers execution fees on destination chain
val estimatedFee = transfer.amountPlanks / 100.toBigInteger()
return CrossChainFeeModel(paidFromHolding = estimatedFee.coerceAtLeast(Balance.ZERO))
}
// Ensure we can calculate fee regardless of what user entered
private fun AssetTransferBase.ensureSafeAmount(): AssetTransferBase {
val minimumSendAmount = destinationChainAsset.planksFromAmount(MINIMUM_SEND_AMOUNT.toBigDecimal())
val safeAmount = amountPlanks.coerceAtLeast(minimumSendAmount)
return replaceAmount(newAmount = safeAmount)
}
private fun CrossChainFeeModel.Companion.fromDryRunResult(
initialAmount: Balance,
transferDryRunResult: XcmTransferDryRunResult
): CrossChainFeeModel {
return with(transferDryRunResult) {
// We do not add `remoteReserve.deliveryFee` since it is paid from holding and not by account
val paidByAccount = origin.deliveryFee
val trapped = origin.trapped + remoteReserve?.trapped.orZero()
val totalFee = initialAmount - destination.depositedAmount - trapped
// We do not subtract `origin.deliveryFee` since it is paid directly from the origin account and thus do not contribute towards execution fee
// We do not subtract `remoteReserve.deliveryFee` since it is paid from holding and thus is already accounted in totalFee
val executionFee = totalFee
CrossChainFeeModel(paidByAccount = paidByAccount, paidFromHolding = executionFee)
}
}
}
@@ -0,0 +1,19 @@
package io.novafoundation.nova.feature_wallet_impl.data.network.crosschain.dynamic.dryRun
import io.novafoundation.nova.feature_wallet_api.data.network.blockhain.types.Balance
class XcmTransferDryRunResult(
val origin: IntermediateSegment,
val remoteReserve: IntermediateSegment?,
val destination: FinalSegment,
) {
class IntermediateSegment(
val deliveryFee: Balance,
val trapped: Balance,
)
class FinalSegment(
val depositedAmount: Balance
)
}
@@ -0,0 +1,347 @@
package io.novafoundation.nova.feature_wallet_impl.data.network.crosschain.dynamic.dryRun
import android.util.Log
import io.novafoundation.nova.common.address.AccountIdKey
import io.novafoundation.nova.common.di.scope.FeatureScope
import io.novafoundation.nova.common.utils.LOG_TAG
import io.novafoundation.nova.common.utils.xcmPalletNameOrNull
import io.novafoundation.nova.feature_wallet_api.data.network.blockhain.assets.AssetSourceRegistry
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.amount
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.model.planksFromAmount
import io.novafoundation.nova.feature_wallet_api.domain.model.xcm.destinationChainLocation
import io.novafoundation.nova.feature_wallet_api.domain.model.xcm.dynamic.DynamicCrossChainTransferConfiguration
import io.novafoundation.nova.feature_wallet_api.domain.model.xcm.dynamic.reserve.isRemoteReserve
import io.novafoundation.nova.feature_wallet_api.domain.model.xcm.dynamic.reserve.remoteReserveLocation
import io.novafoundation.nova.feature_wallet_api.domain.model.xcm.originChainId
import io.novafoundation.nova.feature_wallet_api.domain.model.xcm.originChainLocation
import io.novafoundation.nova.feature_wallet_impl.data.network.blockchain.calls.composeBatchAll
import io.novafoundation.nova.feature_wallet_impl.data.network.blockchain.calls.composeDispatchAs
import io.novafoundation.nova.feature_wallet_impl.data.network.crosschain.dynamic.DynamicCrossChainTransactor
import io.novafoundation.nova.feature_wallet_impl.data.network.crosschain.dynamic.dryRun.XcmTransferDryRunResult.FinalSegment
import io.novafoundation.nova.feature_wallet_impl.data.network.crosschain.dynamic.dryRun.XcmTransferDryRunResult.IntermediateSegment
import io.novafoundation.nova.feature_wallet_impl.data.network.crosschain.dynamic.dryRun.issuing.AssetIssuerRegistry
import io.novafoundation.nova.feature_xcm_api.asset.MultiAssets
import io.novafoundation.nova.feature_xcm_api.asset.requireFungible
import io.novafoundation.nova.feature_xcm_api.message.VersionedRawXcmMessage
import io.novafoundation.nova.feature_xcm_api.multiLocation.RelativeMultiLocation
import io.novafoundation.nova.feature_xcm_api.runtimeApi.dryRun.DryRunApi
import io.novafoundation.nova.feature_xcm_api.runtimeApi.dryRun.model.DryRunEffects
import io.novafoundation.nova.feature_xcm_api.runtimeApi.dryRun.model.OriginCaller
import io.novafoundation.nova.feature_xcm_api.runtimeApi.dryRun.model.getByLocation
import io.novafoundation.nova.feature_xcm_api.runtimeApi.dryRun.model.senderXcmVersion
import io.novafoundation.nova.feature_xcm_api.runtimeApi.getInnerSuccessOrThrow
import io.novafoundation.nova.feature_xcm_api.versions.XcmVersion
import io.novafoundation.nova.feature_xcm_api.versions.versionedXcm
import io.novafoundation.nova.runtime.ext.emptyAccountIdKey
import io.novafoundation.nova.runtime.ext.isUtilityAsset
import io.novafoundation.nova.runtime.ext.utilityAsset
import io.novafoundation.nova.runtime.multiNetwork.ChainRegistry
import io.novafoundation.nova.runtime.multiNetwork.chain.model.Chain
import io.novafoundation.nova.runtime.multiNetwork.getRuntime
import io.novafoundation.nova.runtime.multiNetwork.runtime.repository.findEvent
import io.novasama.substrate_sdk_android.runtime.RuntimeSnapshot
import io.novasama.substrate_sdk_android.runtime.definitions.types.generics.GenericCall
import javax.inject.Inject
interface XcmTransferDryRunner {
suspend fun dryRunXcmTransfer(
config: DynamicCrossChainTransferConfiguration,
transfer: AssetTransferBase,
origin: XcmTransferDryRunOrigin
): Result<XcmTransferDryRunResult>
}
@FeatureScope
class RealXcmTransferDryRunner @Inject constructor(
private val dryRunApi: DryRunApi,
private val chainRegistry: ChainRegistry,
private val assetIssuerRegistry: AssetIssuerRegistry,
private val assetSourceRegistry: AssetSourceRegistry,
private val dynamicCrossChainTransactor: DynamicCrossChainTransactor,
) : XcmTransferDryRunner {
companion object {
private const val MINIMUM_FUND_AMOUNT = 100
private const val FEES_PAID_FEES_ARGUMENT_INDEX = 1
private const val ASSETS_TRAPPED_ARGUMENT_INDEX = 2
}
override suspend fun dryRunXcmTransfer(
config: DynamicCrossChainTransferConfiguration,
transfer: AssetTransferBase,
origin: XcmTransferDryRunOrigin
): Result<XcmTransferDryRunResult> {
return runCatching {
val originResult = dryRunOnOrigin(config, transfer, origin)
val remoteReserveResult = dryRunOnRemoteReserve(config, originResult.forwardedXcm)
val destinationResult = dryRunOnDestination(config, transfer, remoteReserveResult.forwardedXcm)
XcmTransferDryRunResult(
origin = originResult.toPublicResult(),
remoteReserve = remoteReserveResult.takeIfRemoteReserve(config)?.toPublicResult(),
destination = destinationResult.toPublicResult()
)
}
.onFailure { Log.w(LOG_TAG, "Dry run failed", it) }
}
private suspend fun dryRunOnOrigin(
config: DynamicCrossChainTransferConfiguration,
transfer: AssetTransferBase,
origin: XcmTransferDryRunOrigin,
): IntermediateDryRunResult {
val runtime = chainRegistry.getRuntime(config.originChainId)
val xcmResultsVersion = XcmVersion.V4
val (dryRunCall, dryRunOrigin) = constructDryRunCallParams(config, transfer, origin, runtime)
val dryRunResult = dryRunApi.dryRunCall(dryRunOrigin, dryRunCall, xcmResultsVersion, config.originChainId)
.getInnerSuccessOrThrow(LOG_TAG)
val nextHopLocation = (config.transferType.remoteReserveLocation() ?: config.destinationChainLocation).location
val forwardedXcm = searchForwardedXcm(
dryRunEffects = dryRunResult,
destination = nextHopLocation.fromPointOfViewOf(config.originChainLocation.location),
)
val deliveryFee = searchDeliveryFee(dryRunResult, runtime)
val trappedAssets = searchTrappedAssets(dryRunResult, runtime)
return IntermediateDryRunResult(forwardedXcm, deliveryFee, trappedAssets)
}
private suspend fun constructDryRunCallParams(
config: DynamicCrossChainTransferConfiguration,
transfer: AssetTransferBase,
origin: XcmTransferDryRunOrigin,
runtime: RuntimeSnapshot
): OriginCallParams {
return when (origin) {
XcmTransferDryRunOrigin.Fake -> constructDryRunCallFromFakeOrigin(transfer, config, runtime)
is XcmTransferDryRunOrigin.Signed -> constructDryRunCallFromRealOrigin(transfer, config, origin)
}
}
private suspend fun constructDryRunCallFromRealOrigin(
transfer: AssetTransferBase,
config: DynamicCrossChainTransferConfiguration,
origin: XcmTransferDryRunOrigin.Signed,
): OriginCallParams {
val callOnOrigin = dynamicCrossChainTransactor.composeCrossChainTransferCall(config, transfer, crossChainFee = origin.crossChainFee)
return OriginCallParams(
call = callOnOrigin,
origin = OriginCaller.System.Signed(origin.accountId)
)
}
private suspend fun constructDryRunCallFromFakeOrigin(
transfer: AssetTransferBase,
config: DynamicCrossChainTransferConfiguration,
runtime: RuntimeSnapshot,
): OriginCallParams {
val callOnOrigin = dynamicCrossChainTransactor.composeCrossChainTransferCall(config, transfer, crossChainFee = Balance.ZERO)
val dryRunAccount = transfer.originChain.emptyAccountIdKey()
val transferOrigin = OriginCaller.System.Signed(dryRunAccount)
val calls = buildList {
addFundCalls(transfer, dryRunAccount)
val transferCallFromOrigin = runtime.composeDispatchAs(callOnOrigin, transferOrigin)
add(transferCallFromOrigin)
}
val finalOriginCall = runtime.composeBatchAll(calls)
return OriginCallParams(finalOriginCall, OriginCaller.System.Root)
}
private suspend fun MutableList<GenericCall.Instance>.addFundCalls(transfer: AssetTransferBase, dryRunAccount: AccountIdKey) {
val fundAmount = determineFundAmount(transfer)
// Fund native asset first so we can later fund potentially non-sufficient assets
if (!transfer.originChainAsset.isUtilityAsset) {
// Additionally fund native asset to pay delivery fees
val nativeAsset = transfer.originChain.utilityAsset
val planks = nativeAsset.planksFromAmount(MINIMUM_FUND_AMOUNT.toBigDecimal())
val fundNativeAssetCall = assetIssuerRegistry.create(nativeAsset).composeIssueCall(planks, dryRunAccount)
add(fundNativeAssetCall)
}
val fundSendingAssetCall = assetIssuerRegistry.create(transfer.originChainAsset).composeIssueCall(fundAmount, dryRunAccount)
add(fundSendingAssetCall)
}
private suspend fun dryRunOnRemoteReserve(
config: DynamicCrossChainTransferConfiguration,
forwardedFromOrigin: VersionedRawXcmMessage,
): IntermediateDryRunResult {
// No remote reserve - nothing to dry run, return unchanged value
val remoteReserveLocation = config.transferType.remoteReserveLocation()
?: return IntermediateDryRunResult(forwardedFromOrigin, Balance.ZERO, Balance.ZERO)
val runtime = chainRegistry.getRuntime(remoteReserveLocation.chainId)
val originLocation = config.originChainLocation.location
val destinationLocation = config.destinationChainLocation.location
val usedXcmVersion = forwardedFromOrigin.version
val dryRunOrigin = originLocation.fromPointOfViewOf(remoteReserveLocation.location).versionedXcm(usedXcmVersion)
val dryRunResult = dryRunApi.dryRunXcm(dryRunOrigin, forwardedFromOrigin, remoteReserveLocation.chainId)
.getInnerSuccessOrThrow(LOG_TAG)
val destinationOnRemoteReserve = destinationLocation.fromPointOfViewOf(remoteReserveLocation.location)
val forwardedXcm = searchForwardedXcm(
dryRunEffects = dryRunResult,
destination = destinationOnRemoteReserve,
)
val deliveryFee = searchDeliveryFee(dryRunResult, runtime)
val trappedAssets = searchTrappedAssets(dryRunResult, runtime)
return IntermediateDryRunResult(forwardedXcm, deliveryFee, trappedAssets)
}
private suspend fun dryRunOnDestination(
config: DynamicCrossChainTransferConfiguration,
transfer: AssetTransferBase,
forwardedFromPrevious: VersionedRawXcmMessage,
): FinalDryRunResult {
val previousLocation = (config.transferType.remoteReserveLocation() ?: config.originChainLocation).location
val destinationLocation = config.destinationChainLocation
val usedXcmVersion = forwardedFromPrevious.version
val dryRunOrigin = previousLocation.fromPointOfViewOf(destinationLocation.location).versionedXcm(usedXcmVersion)
val dryRunResult = dryRunApi.dryRunXcm(dryRunOrigin, forwardedFromPrevious, destinationLocation.chainId)
.getInnerSuccessOrThrow(LOG_TAG)
val depositedAmount = searchDepositAmount(dryRunResult, transfer.destinationChainAsset, transfer.recipientAccountId)
return FinalDryRunResult(depositedAmount)
}
private fun searchForwardedXcm(
dryRunEffects: DryRunEffects,
destination: RelativeMultiLocation,
): VersionedRawXcmMessage {
return searchForwardedXcmInQueues(dryRunEffects, destination)
}
private suspend fun searchDepositAmount(
dryRunEffects: DryRunEffects,
chainAsset: Chain.Asset,
recipientAccountId: AccountIdKey,
): Balance {
val depositDetector = assetSourceRegistry.getEventDetector(chainAsset)
val deposits = dryRunEffects.emittedEvents.mapNotNull { depositDetector.detectDeposit(it) }
.filter { it.destination == recipientAccountId }
if (deposits.isEmpty()) error("No deposits detected")
return deposits.sumOf { it.amount }
}
private fun searchDeliveryFee(
dryRunEffects: DryRunEffects,
runtimeSnapshot: RuntimeSnapshot,
): Balance {
val xcmPalletName = runtimeSnapshot.metadata.xcmPalletNameOrNull() ?: return Balance.ZERO
val event = dryRunEffects.emittedEvents.findEvent(xcmPalletName, "FeesPaid") ?: return Balance.ZERO
val usedXcmVersion = dryRunEffects.senderXcmVersion()
val feesDecoded = event.arguments[FEES_PAID_FEES_ARGUMENT_INDEX]
val multiAssets = MultiAssets.bind(feesDecoded, usedXcmVersion)
return multiAssets.extractFirstAmount()
}
private fun searchTrappedAssets(
dryRunEffects: DryRunEffects,
runtimeSnapshot: RuntimeSnapshot,
): Balance {
val xcmPalletName = runtimeSnapshot.metadata.xcmPalletNameOrNull() ?: return Balance.ZERO
val event = dryRunEffects.emittedEvents.findEvent(xcmPalletName, "AssetsTrapped") ?: return Balance.ZERO
val feesDecoded = event.arguments[ASSETS_TRAPPED_ARGUMENT_INDEX]
val multiAssets = MultiAssets.bindVersioned(feesDecoded).xcm
return multiAssets.extractFirstAmount()
}
private fun MultiAssets.extractFirstAmount(): Balance {
return if (value.isNotEmpty()) {
value.first().requireFungible().amount
} else {
Balance.ZERO
}
}
private fun searchForwardedXcmInQueues(
dryRunEffects: DryRunEffects,
destination: RelativeMultiLocation
): VersionedRawXcmMessage {
val forwardedXcms = dryRunEffects.forwardedXcms
// For teleport transfers, forwarded XCMs might be empty or structured differently
if (forwardedXcms.isEmpty()) {
error("Dry run did not produce any forwarded XCMs. This transfer type may not support dry run fee estimation.")
}
val usedXcmVersion = dryRunEffects.senderXcmVersion()
val versionedDestination = destination.versionedXcm(usedXcmVersion)
val forwardedXcmsToDestination = forwardedXcms.getByLocation(versionedDestination)
// If destination location not found, try first available forwarded XCM
if (forwardedXcmsToDestination.isEmpty()) {
Log.w(LOG_TAG, "No forwarded XCM found for destination $destination, using first available")
val firstAvailable = forwardedXcms.firstOrNull()?.second?.firstOrNull()
return firstAvailable ?: error("No forwarded XCMs available for dry run")
}
// There should only be one forwarded message during dry run
return forwardedXcmsToDestination.first()
}
private fun determineFundAmount(transfer: AssetTransferBase): Balance {
val amount = (transfer.amount() * 2.toBigDecimal()).coerceAtLeast(MINIMUM_FUND_AMOUNT.toBigDecimal())
return transfer.originChainAsset.planksFromAmount(amount)
}
private fun IntermediateDryRunResult.toPublicResult(): IntermediateSegment {
return IntermediateSegment(
deliveryFee = deliveryFee,
trapped = trapped
)
}
private fun FinalDryRunResult.toPublicResult(): FinalSegment {
return FinalSegment(depositedAmount = depositedAmount)
}
private fun IntermediateDryRunResult.takeIfRemoteReserve(config: DynamicCrossChainTransferConfiguration): IntermediateDryRunResult? {
return takeIf { config.transferType.isRemoteReserve() }
}
private class IntermediateDryRunResult(
val forwardedXcm: VersionedRawXcmMessage,
val deliveryFee: Balance,
val trapped: Balance,
)
private class FinalDryRunResult(
val depositedAmount: Balance
)
private data class OriginCallParams(val call: GenericCall.Instance, val origin: OriginCaller)
}
@@ -0,0 +1,15 @@
package io.novafoundation.nova.feature_wallet_impl.data.network.crosschain.dynamic.dryRun.issuing
import io.novafoundation.nova.common.address.AccountIdKey
import io.novafoundation.nova.feature_wallet_api.data.network.blockhain.types.Balance
import io.novafoundation.nova.feature_xcm_api.runtimeApi.dryRun.model.OriginCaller
import io.novasama.substrate_sdk_android.runtime.definitions.types.generics.GenericCall
interface AssetIssuer {
/**
* Compose a call to issue [amount] of tokens to [destination]
* Implementation can assume execution happens under [OriginCaller.System.Root]
*/
suspend fun composeIssueCall(amount: Balance, destination: AccountIdKey): GenericCall.Instance
}
@@ -0,0 +1,32 @@
package io.novafoundation.nova.feature_wallet_impl.data.network.crosschain.dynamic.dryRun.issuing
import io.novafoundation.nova.common.di.scope.FeatureScope
import io.novafoundation.nova.feature_wallet_api.data.repository.StatemineAssetsRepository
import io.novafoundation.nova.runtime.multiNetwork.ChainRegistry
import io.novafoundation.nova.runtime.multiNetwork.chain.model.Chain
import io.novafoundation.nova.runtime.multiNetwork.chain.model.Chain.Asset.Type
import io.novafoundation.nova.runtime.multiNetwork.getRuntime
import javax.inject.Inject
interface AssetIssuerRegistry {
suspend fun create(chainAsset: Chain.Asset): AssetIssuer
}
@FeatureScope
class RealAssetIssuerRegistry @Inject constructor(
private val chainRegistry: ChainRegistry,
private val statemineAssetsRepository: StatemineAssetsRepository,
) : AssetIssuerRegistry {
override suspend fun create(chainAsset: Chain.Asset): AssetIssuer {
val runtime = chainRegistry.getRuntime(chainAsset.chainId)
return when (val type = chainAsset.type) {
is Type.Native -> NativeAssetIssuer(runtime)
is Type.Statemine -> StatemineAssetIssuer(chainAsset.chainId, type, runtime, statemineAssetsRepository)
is Type.Orml -> OrmlAssetIssuer(type, runtime)
else -> error("Unsupported asset type: $type for ${chainAsset.symbol} on ${chainAsset.chainId}")
}
}
}
@@ -0,0 +1,25 @@
package io.novafoundation.nova.feature_wallet_impl.data.network.crosschain.dynamic.dryRun.issuing
import io.novafoundation.nova.common.address.AccountIdKey
import io.novafoundation.nova.common.utils.Modules
import io.novafoundation.nova.common.utils.composeCall
import io.novafoundation.nova.feature_wallet_api.data.network.blockhain.types.Balance
import io.novasama.substrate_sdk_android.runtime.RuntimeSnapshot
import io.novasama.substrate_sdk_android.runtime.definitions.types.generics.GenericCall
import io.novafoundation.nova.common.utils.PezkuwiAddressConstructor
class NativeAssetIssuer(
private val runtimeSnapshot: RuntimeSnapshot
) : AssetIssuer {
override suspend fun composeIssueCall(amount: Balance, destination: AccountIdKey): GenericCall.Instance {
return runtimeSnapshot.composeCall(
moduleName = Modules.BALANCES,
callName = "force_set_balance",
arguments = mapOf(
"who" to PezkuwiAddressConstructor.constructInstance(runtimeSnapshot.typeRegistry, destination.value),
"new_free" to amount
)
)
}
}
@@ -0,0 +1,30 @@
package io.novafoundation.nova.feature_wallet_impl.data.network.crosschain.dynamic.dryRun.issuing
import io.novafoundation.nova.common.address.AccountIdKey
import io.novafoundation.nova.common.utils.Modules
import io.novafoundation.nova.common.utils.composeCall
import io.novafoundation.nova.feature_wallet_api.data.network.blockhain.types.Balance
import io.novafoundation.nova.runtime.ext.currencyId
import io.novafoundation.nova.runtime.multiNetwork.chain.model.Chain
import io.novasama.substrate_sdk_android.runtime.RuntimeSnapshot
import io.novasama.substrate_sdk_android.runtime.definitions.types.generics.GenericCall
import io.novafoundation.nova.common.utils.PezkuwiAddressConstructor
class OrmlAssetIssuer(
private val ormlType: Chain.Asset.Type.Orml,
private val runtimeSnapshot: RuntimeSnapshot
) : AssetIssuer {
override suspend fun composeIssueCall(amount: Balance, destination: AccountIdKey): GenericCall.Instance {
return runtimeSnapshot.composeCall(
moduleName = Modules.TOKENS,
callName = "set_balance",
arguments = mapOf(
"who" to PezkuwiAddressConstructor.constructInstance(runtimeSnapshot.typeRegistry, destination.value),
"currency_id" to ormlType.currencyId(runtimeSnapshot),
"new_free" to amount,
"new_reserved" to Balance.ZERO
)
)
}
}
@@ -0,0 +1,46 @@
package io.novafoundation.nova.feature_wallet_impl.data.network.crosschain.dynamic.dryRun.issuing
import io.novafoundation.nova.common.address.AccountIdKey
import io.novafoundation.nova.common.utils.composeCall
import io.novafoundation.nova.feature_wallet_api.data.network.blockhain.types.Balance
import io.novafoundation.nova.feature_wallet_api.data.repository.StatemineAssetsRepository
import io.novafoundation.nova.feature_wallet_impl.data.network.blockchain.calls.composeDispatchAs
import io.novafoundation.nova.feature_xcm_api.runtimeApi.dryRun.model.OriginCaller
import io.novafoundation.nova.runtime.ext.palletNameOrDefault
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.prepareIdForEncoding
import io.novasama.substrate_sdk_android.runtime.RuntimeSnapshot
import io.novasama.substrate_sdk_android.runtime.definitions.types.generics.GenericCall
import io.novafoundation.nova.common.utils.PezkuwiAddressConstructor
class StatemineAssetIssuer(
private val chainId: ChainId,
private val assetType: Chain.Asset.Type.Statemine,
private val runtimeSnapshot: RuntimeSnapshot,
private val statemineAssetsRepository: StatemineAssetsRepository,
) : AssetIssuer {
override suspend fun composeIssueCall(amount: Balance, destination: AccountIdKey): GenericCall.Instance {
val assetDetails = statemineAssetsRepository.getAssetDetails(chainId, assetType)
val issuer = assetDetails.issuer
// We're dispatching as issuer since only issuer is allowed to mint tokens
return runtimeSnapshot.composeDispatchAs(
call = composeMint(amount, destination),
origin = OriginCaller.System.Signed(issuer)
)
}
private fun composeMint(amount: Balance, destination: AccountIdKey): GenericCall.Instance {
return runtimeSnapshot.composeCall(
moduleName = assetType.palletNameOrDefault(),
callName = "mint",
arguments = mapOf(
"id" to assetType.prepareIdForEncoding(runtimeSnapshot),
"beneficiary" to PezkuwiAddressConstructor.constructInstance(runtimeSnapshot.typeRegistry, destination.value),
"amount" to amount
)
)
}
}
@@ -0,0 +1,71 @@
package io.novafoundation.nova.feature_wallet_impl.data.network.crosschain.legacy
import io.novafoundation.nova.feature_wallet_impl.data.network.crosschain.JunctionsRemote
import io.novafoundation.nova.runtime.multiNetwork.chain.model.ChainId
import java.math.BigInteger
class LegacyCrossChainTransfersConfigRemote(
val assetsLocation: Map<String, LegacyReserveLocationRemote>?,
val instructions: Map<String, List<String>>?,
val networkDeliveryFee: Map<String, LegacyNetworkDeliveryFeeRemote>?,
val networkBaseWeight: Map<String, BigInteger>?,
val chains: List<LegacyCrossChainOriginChainRemote>?
)
class LegacyReserveLocationRemote(
val chainId: ChainId,
val reserveFee: LegacyXcmFeeRemote?,
val multiLocation: JunctionsRemote
)
class LegacyNetworkDeliveryFeeRemote(
val toParent: LegacyDeliveryFeeConfigRemote?,
val toParachain: LegacyDeliveryFeeConfigRemote?
)
class LegacyDeliveryFeeConfigRemote(
val type: String,
val factorPallet: String,
val sizeBase: BigInteger,
val sizeFactor: BigInteger,
val alwaysHoldingPays: Boolean?
)
class LegacyCrossChainOriginChainRemote(
val chainId: ChainId,
val assets: List<LegacyCrossChainOriginAssetRemote>
)
class LegacyCrossChainOriginAssetRemote(
val assetId: Int,
val assetLocation: String,
val assetLocationPath: LegacyAssetLocationPathRemote,
val xcmTransfers: List<LegacyXcmTransferRemote>,
)
class LegacyXcmTransferRemote(
val destination: LegacyXcmDestinationRemote,
val type: String,
)
class LegacyXcmDestinationRemote(
val chainId: ChainId,
val assetId: Int,
val fee: LegacyXcmFeeRemote
)
class LegacyXcmFeeRemote(
val mode: Mode,
val instructions: String
) {
class Mode(
val type: String,
val value: String?
)
}
class LegacyAssetLocationPathRemote(
val type: String,
val path: JunctionsRemote?
)
@@ -0,0 +1,160 @@
package io.novafoundation.nova.feature_wallet_impl.data.network.crosschain.legacy
import io.novafoundation.nova.common.address.intoKey
import io.novafoundation.nova.common.data.network.runtime.binding.Weight
import io.novafoundation.nova.common.di.scope.FeatureScope
import io.novafoundation.nova.common.utils.xTokensName
import io.novafoundation.nova.common.utils.xcmPalletName
import io.novafoundation.nova.feature_wallet_api.data.network.blockhain.assets.AssetSourceRegistry
import io.novafoundation.nova.feature_wallet_api.data.network.blockhain.assets.tranfers.AssetTransferBase
import io.novafoundation.nova.feature_wallet_api.data.network.blockhain.types.Balance
import io.novafoundation.nova.feature_wallet_api.domain.model.xcm.legacy.LegacyCrossChainTransferConfiguration
import io.novafoundation.nova.feature_wallet_api.domain.model.xcm.legacy.LegacyXcmTransferMethod
import io.novafoundation.nova.feature_wallet_impl.data.network.crosschain.common.TransferAssetUsingTypeTransactor
import io.novafoundation.nova.feature_xcm_api.asset.MultiAsset
import io.novafoundation.nova.feature_xcm_api.asset.MultiAssets
import io.novafoundation.nova.feature_xcm_api.multiLocation.RelativeMultiLocation
import io.novafoundation.nova.feature_xcm_api.multiLocation.plus
import io.novafoundation.nova.feature_xcm_api.multiLocation.toMultiLocation
import io.novafoundation.nova.feature_xcm_api.versions.detector.XcmVersionDetector
import io.novafoundation.nova.feature_xcm_api.versions.orDefault
import io.novafoundation.nova.feature_xcm_api.versions.toEncodableInstance
import io.novafoundation.nova.feature_xcm_api.versions.versionedXcm
import io.novafoundation.nova.feature_xcm_api.weight.WeightLimit
import io.novafoundation.nova.runtime.ext.accountIdOrDefault
import io.novasama.substrate_sdk_android.runtime.extrinsic.builder.ExtrinsicBuilder
import io.novasama.substrate_sdk_android.runtime.extrinsic.call
import java.math.BigInteger
import javax.inject.Inject
@FeatureScope
class LegacyCrossChainTransactor @Inject constructor(
private val weigher: LegacyCrossChainWeigher,
private val xcmVersionDetector: XcmVersionDetector,
private val assetSourceRegistry: AssetSourceRegistry,
private val usingTypeTransactor: TransferAssetUsingTypeTransactor,
) {
context(ExtrinsicBuilder)
suspend fun crossChainTransfer(
configuration: LegacyCrossChainTransferConfiguration,
transfer: AssetTransferBase,
crossChainFee: Balance
) {
when (configuration.transferMethod) {
LegacyXcmTransferMethod.X_TOKENS -> xTokensTransfer(configuration, transfer, crossChainFee)
LegacyXcmTransferMethod.XCM_PALLET_RESERVE -> xcmPalletReserveTransfer(configuration, transfer, crossChainFee)
LegacyXcmTransferMethod.XCM_PALLET_TELEPORT -> xcmPalletTeleport(configuration, transfer, crossChainFee)
LegacyXcmTransferMethod.XCM_PALLET_TRANSFER_ASSETS -> xcmPalletTransferAssets(configuration, transfer, crossChainFee)
LegacyXcmTransferMethod.UNKNOWN -> throw IllegalArgumentException("Unknown transfer type")
}
}
suspend fun requiredRemainingAmountAfterTransfer(
configuration: LegacyCrossChainTransferConfiguration
): Balance {
val chainAsset = configuration.originChainAsset
return assetSourceRegistry.sourceFor(chainAsset).balance.existentialDeposit(chainAsset)
}
private suspend fun ExtrinsicBuilder.xTokensTransfer(
configuration: LegacyCrossChainTransferConfiguration,
assetTransfer: AssetTransferBase,
crossChainFee: Balance
) {
val multiAsset = configuration.multiAssetFor(assetTransfer, crossChainFee)
val fullDestinationLocation = configuration.destinationChainLocation + assetTransfer.beneficiaryLocation()
val requiredDestWeight = weigher.estimateRequiredDestWeight(configuration)
val lowestMultiLocationVersion = xcmVersionDetector.lowestPresentMultiLocationVersion(assetTransfer.originChain.id).orDefault()
val lowestMultiAssetVersion = xcmVersionDetector.lowestPresentMultiAssetVersion(assetTransfer.originChain.id).orDefault()
call(
moduleName = runtime.metadata.xTokensName(),
callName = "transfer_multiasset",
arguments = mapOf(
"asset" to multiAsset.versionedXcm(lowestMultiAssetVersion).toEncodableInstance(),
"dest" to fullDestinationLocation.versionedXcm(lowestMultiLocationVersion).toEncodableInstance(),
// depending on the version of the pallet, only one of weights arguments going to be encoded
"dest_weight" to destWeightEncodable(requiredDestWeight),
"dest_weight_limit" to WeightLimit.Unlimited.toEncodableInstance()
)
)
}
private fun destWeightEncodable(weight: Weight): Any = weight
private suspend fun ExtrinsicBuilder.xcmPalletTransferAssets(
configuration: LegacyCrossChainTransferConfiguration,
assetTransfer: AssetTransferBase,
crossChainFee: Balance
) {
val call = usingTypeTransactor.composeCall(configuration, assetTransfer, crossChainFee)
call(call)
}
private suspend fun ExtrinsicBuilder.xcmPalletReserveTransfer(
configuration: LegacyCrossChainTransferConfiguration,
assetTransfer: AssetTransferBase,
crossChainFee: Balance
) {
xcmPalletTransfer(
configuration = configuration,
assetTransfer = assetTransfer,
crossChainFee = crossChainFee,
callName = "limited_reserve_transfer_assets"
)
}
private suspend fun ExtrinsicBuilder.xcmPalletTeleport(
configuration: LegacyCrossChainTransferConfiguration,
assetTransfer: AssetTransferBase,
crossChainFee: Balance
) {
xcmPalletTransfer(
configuration = configuration,
assetTransfer = assetTransfer,
crossChainFee = crossChainFee,
callName = "limited_teleport_assets"
)
}
private suspend fun ExtrinsicBuilder.xcmPalletTransfer(
configuration: LegacyCrossChainTransferConfiguration,
assetTransfer: AssetTransferBase,
crossChainFee: Balance,
callName: String
) {
val lowestMultiLocationVersion = xcmVersionDetector.lowestPresentMultiLocationVersion(assetTransfer.originChain.id).orDefault()
val lowestMultiAssetsVersion = xcmVersionDetector.lowestPresentMultiAssetsVersion(assetTransfer.originChain.id).orDefault()
val multiAsset = configuration.multiAssetFor(assetTransfer, crossChainFee)
call(
moduleName = runtime.metadata.xcmPalletName(),
callName = callName,
arguments = mapOf(
"dest" to configuration.destinationChainLocation.versionedXcm(lowestMultiLocationVersion).toEncodableInstance(),
"beneficiary" to assetTransfer.beneficiaryLocation().versionedXcm(lowestMultiLocationVersion).toEncodableInstance(),
"assets" to MultiAssets(multiAsset).versionedXcm(lowestMultiAssetsVersion).toEncodableInstance(),
"fee_asset_item" to BigInteger.ZERO,
"weight_limit" to WeightLimit.Unlimited.toEncodableInstance()
)
)
}
private fun LegacyCrossChainTransferConfiguration.multiAssetFor(
transfer: AssetTransferBase,
crossChainFee: Balance
): MultiAsset {
// we add cross chain fee top of entered amount so received amount will be no less than entered one
val planks = transfer.amountPlanks + crossChainFee
return MultiAsset.from(assetLocation, planks)
}
private fun AssetTransferBase.beneficiaryLocation(): RelativeMultiLocation {
val accountId = destinationChain.accountIdOrDefault(recipient).intoKey()
return accountId.toMultiLocation()
}
}
@@ -0,0 +1,303 @@
package io.novafoundation.nova.feature_wallet_impl.data.network.crosschain.legacy
import io.novafoundation.nova.common.data.network.runtime.binding.ParaId
import io.novafoundation.nova.common.data.network.runtime.binding.Weight
import io.novafoundation.nova.common.data.network.runtime.binding.bindNumber
import io.novafoundation.nova.common.di.scope.FeatureScope
import io.novafoundation.nova.common.utils.BigRational
import io.novafoundation.nova.common.utils.argument
import io.novafoundation.nova.common.utils.fixedU128
import io.novafoundation.nova.common.utils.orZero
import io.novafoundation.nova.common.utils.requireActualType
import io.novafoundation.nova.common.utils.xcmPalletName
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_wallet_api.data.network.blockhain.types.Balance
import io.novafoundation.nova.feature_wallet_api.data.network.crosschain.CrossChainFeeModel
import io.novafoundation.nova.feature_wallet_api.data.network.crosschain.orZero
import io.novafoundation.nova.feature_wallet_api.data.network.crosschain.plus
import io.novafoundation.nova.feature_wallet_api.data.network.crosschain.zero
import io.novafoundation.nova.feature_wallet_api.domain.model.xcm.legacy.CrossChainFeeConfiguration
import io.novafoundation.nova.feature_wallet_api.domain.model.xcm.legacy.DeliveryFeeConfiguration
import io.novafoundation.nova.feature_wallet_api.domain.model.xcm.legacy.LegacyCrossChainTransferConfiguration
import io.novafoundation.nova.feature_wallet_api.domain.model.xcm.legacy.LegacyCrossChainTransfersConfiguration.XcmFee.Mode
import io.novafoundation.nova.feature_wallet_api.domain.model.xcm.legacy.XCMInstructionType
import io.novafoundation.nova.feature_wallet_api.domain.model.xcm.legacy.weightToFee
import io.novafoundation.nova.feature_wallet_api.domain.model.xcm.originChainId
import io.novafoundation.nova.feature_wallet_impl.data.network.crosschain.xcmExecute
import io.novafoundation.nova.feature_xcm_api.asset.MultiAsset
import io.novafoundation.nova.feature_xcm_api.asset.MultiAssetFilter
import io.novafoundation.nova.feature_xcm_api.asset.MultiAssets
import io.novafoundation.nova.feature_xcm_api.message.XcmInstruction
import io.novafoundation.nova.feature_xcm_api.message.XcmMessage
import io.novafoundation.nova.feature_xcm_api.multiLocation.RelativeMultiLocation
import io.novafoundation.nova.feature_xcm_api.multiLocation.isHere
import io.novafoundation.nova.feature_xcm_api.multiLocation.paraIdOrNull
import io.novafoundation.nova.feature_xcm_api.multiLocation.toMultiLocation
import io.novafoundation.nova.feature_xcm_api.versions.VersionedXcm
import io.novafoundation.nova.feature_xcm_api.versions.detector.XcmVersionDetector
import io.novafoundation.nova.feature_xcm_api.versions.orDefault
import io.novafoundation.nova.feature_xcm_api.versions.toEncodableInstance
import io.novafoundation.nova.feature_xcm_api.versions.versionedXcm
import io.novafoundation.nova.feature_xcm_api.weight.WeightLimit
import io.novafoundation.nova.runtime.di.REMOTE_STORAGE_SOURCE
import io.novafoundation.nova.runtime.ext.emptyAccountIdKey
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.getRuntime
import io.novafoundation.nova.runtime.storage.source.StorageDataSource
import io.novasama.substrate_sdk_android.runtime.definitions.types.bytes
import io.novasama.substrate_sdk_android.runtime.metadata.call
import io.novasama.substrate_sdk_android.runtime.metadata.module
import io.novasama.substrate_sdk_android.runtime.metadata.storage
import java.math.BigInteger
import javax.inject.Inject
import javax.inject.Named
// TODO: Currently message doesn't contain setTopic command in the end. It will come with XCMv3 support
private const val SET_TOPIC_SIZE = 33
@FeatureScope
class LegacyCrossChainWeigher @Inject constructor(
@Named(REMOTE_STORAGE_SOURCE)
private val storageDataSource: StorageDataSource,
private val extrinsicService: ExtrinsicService,
private val chainRegistry: ChainRegistry,
private val xcmVersionDetector: XcmVersionDetector
) {
fun estimateRequiredDestWeight(transferConfiguration: LegacyCrossChainTransferConfiguration): Weight {
val destinationWeight = transferConfiguration.destinationFee.estimatedWeight()
val reserveWeight = transferConfiguration.reserveFee?.estimatedWeight().orZero()
return destinationWeight.max(reserveWeight)
}
suspend fun estimateFee(amount: Balance, config: LegacyCrossChainTransferConfiguration): CrossChainFeeModel = with(config) {
// Reserve fee may be zero if xcm transfer doesn't reserve tokens
val reserveFeeAmount = calculateFee(amount, reserveFee, reserveChainLocation)
val destinationFeeAmount = calculateFee(amount, destinationFee, destinationChainLocation)
return reserveFeeAmount + destinationFeeAmount
}
private suspend fun LegacyCrossChainTransferConfiguration.calculateFee(
amount: Balance,
feeConfig: CrossChainFeeConfiguration?,
chainLocation: RelativeMultiLocation
): CrossChainFeeModel {
return when (feeConfig) {
null -> CrossChainFeeModel.zero()
else -> {
val isSendingFromOrigin = originChainId == feeConfig.from.chainId
val feeAmount = feeFor(amount, feeConfig)
val deliveryFee = deliveryFeeFor(amount, feeConfig, chainLocation, isSendingFromOrigin = isSendingFromOrigin)
feeAmount.orZero() + deliveryFee.orZero()
}
}
}
private suspend fun LegacyCrossChainTransferConfiguration.feeFor(amount: Balance, feeConfig: CrossChainFeeConfiguration): CrossChainFeeModel {
val chain = chainRegistry.getChain(feeConfig.to.chainId)
val maxWeight = feeConfig.estimatedWeight()
return when (val mode = feeConfig.to.xcmFeeType.mode) {
is Mode.Proportional -> CrossChainFeeModel(paidFromHolding = mode.weightToFee(maxWeight))
Mode.Standard -> {
val xcmMessage = xcmMessage(feeConfig.to.xcmFeeType.instructions, chain, amount)
val paymentInfo = extrinsicService.paymentInfo(
chain,
TransactionOrigin.SelectedWallet
) {
xcmExecute(xcmMessage, maxWeight)
}
CrossChainFeeModel(paidFromHolding = paymentInfo.partialFee)
}
Mode.Unknown -> CrossChainFeeModel.zero()
}
}
private suspend fun LegacyCrossChainTransferConfiguration.deliveryFeeFor(
amount: Balance,
config: CrossChainFeeConfiguration,
destinationChainLocation: RelativeMultiLocation,
isSendingFromOrigin: Boolean
): CrossChainFeeModel {
val deliveryFeeConfiguration = config.from.deliveryFeeConfiguration ?: return CrossChainFeeModel.zero()
val deliveryConfig = deliveryFeeConfiguration.getDeliveryConfig(destinationChainLocation)
val deliveryFeeFactor: BigInteger = queryDeliveryFeeFactor(config.from.chainId, deliveryConfig.factorPallet, destinationChainLocation)
val xcmMessageSize = getXcmMessageSize(amount, config)
val xcmMessageSizeWithTopic = xcmMessageSize + SET_TOPIC_SIZE.toBigInteger()
val feeSize = (deliveryConfig.sizeBase + xcmMessageSizeWithTopic * deliveryConfig.sizeFactor)
val deliveryFee = BigRational.fixedU128(deliveryFeeFactor * feeSize).integralQuotient
val isSenderPaysOriginDelivery = !deliveryConfig.alwaysHoldingPays
return if (isSenderPaysOriginDelivery && isSendingFromOrigin) {
CrossChainFeeModel(paidByAccount = deliveryFee)
} else {
CrossChainFeeModel(paidFromHolding = deliveryFee)
}
}
private suspend fun LegacyCrossChainTransferConfiguration.getXcmMessageSize(amount: Balance, config: CrossChainFeeConfiguration): BigInteger {
val chain = chainRegistry.getChain(config.to.chainId)
val runtime = chainRegistry.getRuntime(config.to.chainId)
val xcmMessage = xcmMessage(config.to.xcmFeeType.instructions, chain, amount)
.toEncodableInstance()
return runtime.metadata
.module(runtime.metadata.xcmPalletName())
.call("execute")
.argument("message")
.requireActualType()
.bytes(runtime, xcmMessage)
.size.toBigInteger()
}
private fun DeliveryFeeConfiguration.getDeliveryConfig(destinationChainLocation: RelativeMultiLocation): DeliveryFeeConfiguration.Type.Exponential {
val isParent = destinationChainLocation.interior.isHere()
val configType = when {
isParent -> toParent
else -> toParachain
}
return configType.asExponentialOrThrow()
}
private fun DeliveryFeeConfiguration.Type?.asExponentialOrThrow(): DeliveryFeeConfiguration.Type.Exponential {
return this as? DeliveryFeeConfiguration.Type.Exponential ?: throw IllegalStateException("Unknown delivery fee type")
}
private suspend fun queryDeliveryFeeFactor(
chainId: ChainId,
pallet: String,
destinationMultiLocation: RelativeMultiLocation,
): BigInteger {
return when {
destinationMultiLocation.interior.isHere() -> xcmParentDeliveryFeeFactor(chainId, pallet)
else -> {
val paraId = destinationMultiLocation.paraIdOrNull() ?: throw IllegalStateException("ParaId must be not null")
xcmParachainDeliveryFeeFactor(chainId, pallet, paraId)
}
}
}
private fun CrossChainFeeConfiguration.estimatedWeight(): Weight {
val instructionTypes = to.xcmFeeType.instructions
return to.instructionWeight * instructionTypes.size.toBigInteger()
}
private suspend fun LegacyCrossChainTransferConfiguration.xcmMessage(
instructionTypes: List<XCMInstructionType>,
chain: Chain,
amount: Balance
): VersionedXcm<XcmMessage> {
val instructions = instructionTypes.mapNotNull { instructionType -> xcmInstruction(instructionType, chain, amount) }
val message = XcmMessage(instructions)
val xcmVersion = xcmVersionDetector.lowestPresentMultiLocationVersion(chain.id).orDefault()
return message.versionedXcm(xcmVersion)
}
private fun LegacyCrossChainTransferConfiguration.xcmInstruction(
instructionType: XCMInstructionType,
chain: Chain,
amount: Balance
): XcmInstruction? {
return when (instructionType) {
XCMInstructionType.ReserveAssetDeposited -> reserveAssetDeposited(amount)
XCMInstructionType.ClearOrigin -> clearOrigin()
XCMInstructionType.BuyExecution -> buyExecution(amount)
XCMInstructionType.DepositAsset -> depositAsset(chain)
XCMInstructionType.WithdrawAsset -> withdrawAsset(amount)
XCMInstructionType.DepositReserveAsset -> depositReserveAsset()
XCMInstructionType.ReceiveTeleportedAsset -> receiveTeleportedAsset(amount)
XCMInstructionType.UNKNOWN -> null
}
}
private fun LegacyCrossChainTransferConfiguration.reserveAssetDeposited(amount: Balance) =
XcmInstruction.ReserveAssetDeposited(
assets = MultiAssets(
sendingAssetAmountOf(amount)
)
)
private fun LegacyCrossChainTransferConfiguration.receiveTeleportedAsset(amount: Balance) =
XcmInstruction.ReceiveTeleportedAsset(
assets = MultiAssets(
sendingAssetAmountOf(amount)
)
)
@Suppress("unused")
private fun LegacyCrossChainTransferConfiguration.clearOrigin() = XcmInstruction.ClearOrigin
private fun LegacyCrossChainTransferConfiguration.buyExecution(amount: Balance): XcmInstruction.BuyExecution {
return XcmInstruction.BuyExecution(
fees = sendingAssetAmountOf(amount),
weightLimit = WeightLimit.Unlimited
)
}
@Suppress("unused")
private fun LegacyCrossChainTransferConfiguration.depositAsset(chain: Chain): XcmInstruction.DepositAsset {
return XcmInstruction.DepositAsset(
assets = MultiAssetFilter.Wild.All,
beneficiary = chain.emptyBeneficiaryMultiLocation()
)
}
private fun LegacyCrossChainTransferConfiguration.withdrawAsset(amount: Balance): XcmInstruction.WithdrawAsset {
return XcmInstruction.WithdrawAsset(
assets = MultiAssets(
sendingAssetAmountOf(amount)
)
)
}
private fun LegacyCrossChainTransferConfiguration.depositReserveAsset(): XcmInstruction {
return XcmInstruction.DepositReserveAsset(
assets = MultiAssetFilter.Wild.All,
dest = destinationChainLocation,
xcm = XcmMessage(emptyList())
)
}
private fun LegacyCrossChainTransferConfiguration.sendingAssetAmountOf(planks: Balance): MultiAsset {
return MultiAsset.from(
amount = planks,
multiLocation = assetLocation,
)
}
private fun Chain.emptyBeneficiaryMultiLocation(): RelativeMultiLocation = emptyAccountIdKey().toMultiLocation()
private suspend fun xcmParachainDeliveryFeeFactor(chainId: ChainId, moduleName: String, paraId: ParaId): BigInteger {
return storageDataSource.query(chainId, applyStorageDefault = true) {
runtime.metadata.module(moduleName).storage("DeliveryFeeFactor")
.query(
paraId,
binding = ::bindNumber
)
}
}
private suspend fun xcmParentDeliveryFeeFactor(chainId: ChainId, moduleName: String): BigInteger {
return storageDataSource.query(chainId) {
runtime.metadata.module(moduleName).storage("UpwardDeliveryFeeFactor")
.query(binding = ::bindNumber)
}
}
}
@@ -0,0 +1,61 @@
package io.novafoundation.nova.feature_wallet_impl.data.network.crosschain.validations
import io.novafoundation.nova.common.utils.atLeastZero
import io.novafoundation.nova.common.utils.orZero
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_account_api.data.model.amountByExecutingAccount
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.tranfers.AssetTransferPayload
import io.novafoundation.nova.feature_wallet_api.data.network.blockhain.assets.tranfers.AssetTransferValidationFailure
import io.novafoundation.nova.feature_wallet_api.data.network.blockhain.assets.tranfers.AssetTransferValidationFailure.NotEnoughFunds.ToStayAboveEdBeforePayingDeliveryFees
import io.novafoundation.nova.feature_wallet_api.data.network.blockhain.assets.tranfers.AssetTransfersValidation
import io.novafoundation.nova.feature_wallet_api.data.network.blockhain.assets.tranfers.AssetTransfersValidationSystemBuilder
import io.novafoundation.nova.feature_wallet_api.data.network.blockhain.assets.tranfers.amountInPlanks
import io.novafoundation.nova.feature_wallet_api.data.network.blockhain.assets.tranfers.isSendingCommissionAsset
import io.novasama.substrate_sdk_android.hash.isPositive
class CannotDropBelowEdWhenPayingDeliveryFeeValidation(
private val assetSourceRegistry: AssetSourceRegistry
) : AssetTransfersValidation {
override suspend fun validate(value: AssetTransferPayload): ValidationStatus<AssetTransferValidationFailure> {
if (!value.isSendingCommissionAsset) return valid()
val existentialDeposit = assetSourceRegistry.existentialDepositInPlanks(value.transfer.originChainAsset)
val deliveryFeePart = value.originFee.deliveryFee?.amount.orZero()
val paysDeliveryFee = deliveryFeePart.isPositive()
val networkFeePlanks = value.originFee.submissionFee.amountByExecutingAccount
val crossChainFeePlanks = value.crossChainFee?.amount.orZero()
val sendingAmount = value.transfer.amountInPlanks + crossChainFeePlanks
val requiredAmountWhenPayingDeliveryFee = sendingAmount + networkFeePlanks + deliveryFeePart + existentialDeposit
val balanceCountedTowardsEd = value.originUsedAsset.balanceCountedTowardsEDInPlanks
return when {
!paysDeliveryFee -> valid()
requiredAmountWhenPayingDeliveryFee <= balanceCountedTowardsEd -> valid()
else -> {
val availableBalance = (balanceCountedTowardsEd - networkFeePlanks - deliveryFeePart - crossChainFeePlanks - existentialDeposit).atLeastZero()
validationError(
ToStayAboveEdBeforePayingDeliveryFees(
maxPossibleTransferAmount = availableBalance,
chainAsset = value.transfer.originChainAsset
)
)
}
}
}
}
fun AssetTransfersValidationSystemBuilder.cannotDropBelowEdBeforePayingDeliveryFee(
assetSourceRegistry: AssetSourceRegistry
) = validate(CannotDropBelowEdWhenPayingDeliveryFeeValidation(assetSourceRegistry))
@@ -0,0 +1,34 @@
package io.novafoundation.nova.feature_wallet_impl.data.network.crosschain.validations
import io.novafoundation.nova.common.utils.orZero
import io.novafoundation.nova.common.validation.ValidationStatus
import io.novafoundation.nova.common.validation.isTrueOrError
import io.novafoundation.nova.feature_account_api.data.model.decimalAmount
import io.novafoundation.nova.feature_account_api.data.model.decimalAmountByExecutingAccount
import io.novafoundation.nova.feature_wallet_api.data.network.blockhain.assets.tranfers.AssetTransferPayload
import io.novafoundation.nova.feature_wallet_api.data.network.blockhain.assets.tranfers.AssetTransferValidationFailure
import io.novafoundation.nova.feature_wallet_api.data.network.blockhain.assets.tranfers.AssetTransfersValidation
import io.novafoundation.nova.feature_wallet_api.data.network.blockhain.assets.tranfers.AssetTransfersValidationSystemBuilder
import io.novafoundation.nova.feature_wallet_api.data.network.blockhain.assets.tranfers.originFeeListInUsedAsset
class CrossChainFeeValidation : AssetTransfersValidation {
override suspend fun validate(value: AssetTransferPayload): ValidationStatus<AssetTransferValidationFailure> {
val originFeeSum = value.originFeeListInUsedAsset.sumOf { it.decimalAmountByExecutingAccount }
val remainingBalanceAfterTransfer = value.originUsedAsset.transferable - value.transfer.amount - originFeeSum
val crossChainFee = value.crossChainFee?.decimalAmount.orZero()
val remainsEnoughToPayCrossChainFees = remainingBalanceAfterTransfer >= crossChainFee
return remainsEnoughToPayCrossChainFees isTrueOrError {
AssetTransferValidationFailure.NotEnoughFunds.ToPayCrossChainFee(
usedAsset = value.transfer.originChainAsset,
fee = crossChainFee,
remainingBalanceAfterTransfer = remainingBalanceAfterTransfer
)
}
}
}
fun AssetTransfersValidationSystemBuilder.canPayCrossChainFee() = validate(CrossChainFeeValidation())
@@ -0,0 +1,55 @@
package io.novafoundation.nova.feature_wallet_impl.data.network.crosschain.validations
import io.novafoundation.nova.common.di.scope.FeatureScope
import io.novafoundation.nova.common.utils.orZero
import io.novafoundation.nova.common.validation.ValidationSystem
import io.novafoundation.nova.feature_account_api.data.model.decimalAmount
import io.novafoundation.nova.feature_wallet_api.data.network.blockhain.assets.AssetSourceRegistry
import io.novafoundation.nova.feature_wallet_api.data.network.blockhain.assets.tranfers.AssetTransfersValidationSystem
import io.novafoundation.nova.feature_wallet_api.data.network.crosschain.CrossChainValidationSystemProvider
import io.novafoundation.nova.feature_wallet_api.domain.validation.EnoughTotalToStayAboveEDValidationFactory
import io.novafoundation.nova.feature_wallet_api.domain.validation.PhishingValidationFactory
import io.novafoundation.nova.feature_wallet_impl.data.network.blockchain.assets.transfers.validations.doNotCrossExistentialDepositInUsedAsset
import io.novafoundation.nova.feature_wallet_impl.data.network.blockchain.assets.transfers.validations.notDeadRecipientInCommissionAsset
import io.novafoundation.nova.feature_wallet_impl.data.network.blockchain.assets.transfers.validations.notDeadRecipientInUsedAsset
import io.novafoundation.nova.feature_wallet_impl.data.network.blockchain.assets.transfers.validations.notPhishingRecipient
import io.novafoundation.nova.feature_wallet_impl.data.network.blockchain.assets.transfers.validations.positiveAmount
import io.novafoundation.nova.feature_wallet_impl.data.network.blockchain.assets.transfers.validations.recipientIsNotSystemAccount
import io.novafoundation.nova.feature_wallet_impl.data.network.blockchain.assets.transfers.validations.sufficientCommissionBalanceToStayAboveED
import io.novafoundation.nova.feature_wallet_impl.data.network.blockchain.assets.transfers.validations.sufficientTransferableBalanceToPayOriginFee
import io.novafoundation.nova.feature_wallet_impl.data.network.blockchain.assets.transfers.validations.validAddress
import javax.inject.Inject
@FeatureScope
class RealCrossChainValidationSystemProvider @Inject constructor(
private val phishingValidationFactory: PhishingValidationFactory,
private val enoughTotalToStayAboveEDValidationFactory: EnoughTotalToStayAboveEDValidationFactory,
private val dryRunSucceedsValidationFactory: DryRunSucceedsValidationFactory,
private val assetSourceRegistry: AssetSourceRegistry,
) : CrossChainValidationSystemProvider {
override fun createValidationSystem(): AssetTransfersValidationSystem = ValidationSystem {
positiveAmount()
recipientIsNotSystemAccount()
validAddress()
notPhishingRecipient(phishingValidationFactory)
notDeadRecipientInCommissionAsset(assetSourceRegistry)
notDeadRecipientInUsedAsset(assetSourceRegistry)
sufficientCommissionBalanceToStayAboveED(enoughTotalToStayAboveEDValidationFactory)
sufficientTransferableBalanceToPayOriginFee()
canPayCrossChainFee()
cannotDropBelowEdBeforePayingDeliveryFee(assetSourceRegistry)
doNotCrossExistentialDepositInUsedAsset(
assetSourceRegistry = assetSourceRegistry,
extraAmount = { it.transfer.amount + it.crossChainFee?.decimalAmount.orZero() }
)
dryRunSucceedsValidationFactory.dryRunSucceeds()
}
}
@@ -0,0 +1,47 @@
package io.novafoundation.nova.feature_wallet_impl.data.network.crosschain.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_wallet_api.data.network.blockhain.assets.tranfers.AssetTransferPayload
import io.novafoundation.nova.feature_wallet_api.data.network.blockhain.assets.tranfers.AssetTransferValidationFailure
import io.novafoundation.nova.feature_wallet_api.data.network.blockhain.assets.tranfers.AssetTransfersValidation
import io.novafoundation.nova.feature_wallet_api.data.network.blockhain.assets.tranfers.AssetTransfersValidationSystemBuilder
import io.novafoundation.nova.feature_wallet_api.data.network.blockhain.assets.tranfers.senderAccountId
import io.novafoundation.nova.feature_wallet_api.data.network.crosschain.XcmTransferDryRunOrigin
import io.novafoundation.nova.feature_wallet_api.domain.interfaces.CrossChainTransfersUseCase
import kotlinx.coroutines.CoroutineScope
import javax.inject.Inject
import kotlin.coroutines.coroutineContext
@FeatureScope
class DryRunSucceedsValidationFactory @Inject constructor(
private val crossChainTransfersUseCase: CrossChainTransfersUseCase,
) {
context(AssetTransfersValidationSystemBuilder)
fun dryRunSucceeds() {
validate(DryRunSucceedsValidation(crossChainTransfersUseCase))
}
}
private class DryRunSucceedsValidation(
private val crossChainTransfersUseCase: CrossChainTransfersUseCase,
) : AssetTransfersValidation {
override suspend fun validate(value: AssetTransferPayload): ValidationStatus<AssetTransferValidationFailure> {
// Skip validation if it is not a cross chain transfer
val crossChainFee = value.crossChainFee ?: return valid()
val dryRunResult = crossChainTransfersUseCase.dryRunTransferIfPossible(
transfer = value.transfer,
origin = XcmTransferDryRunOrigin.Signed(value.transfer.senderAccountId, crossChainFee.amount),
computationalScope = CoroutineScope(coroutineContext)
)
return dryRunResult.isSuccess isTrueOrError {
AssetTransferValidationFailure.DryRunFailed
}
}
}
@@ -0,0 +1,24 @@
package io.novafoundation.nova.feature_wallet_impl.data.network.etherscan
import io.novafoundation.nova.feature_wallet_impl.BuildConfig
import io.novafoundation.nova.runtime.ext.Ids
import io.novafoundation.nova.runtime.multiNetwork.chain.model.Chain
import io.novafoundation.nova.runtime.multiNetwork.chain.model.ChainId
class EtherscanApiKeys(private val keys: Map<ChainId, String>) {
companion object {
fun default(): EtherscanApiKeys {
return EtherscanApiKeys(
mapOf(
Chain.Ids.MOONBEAM to BuildConfig.EHTERSCAN_API_KEY_MOONBEAM,
Chain.Ids.MOONRIVER to BuildConfig.EHTERSCAN_API_KEY_MOONRIVER,
Chain.Ids.ETHEREUM to BuildConfig.EHTERSCAN_API_KEY_ETHEREUM
)
)
}
}
fun keyFor(chainId: ChainId): String? = keys[chainId]
}
@@ -0,0 +1,70 @@
package io.novafoundation.nova.feature_wallet_impl.data.network.etherscan
import io.novafoundation.nova.feature_wallet_impl.data.network.etherscan.model.EtherscanAccountTransfer
import io.novafoundation.nova.feature_wallet_impl.data.network.etherscan.model.EtherscanNormalTxResponse
import io.novafoundation.nova.feature_wallet_impl.data.network.etherscan.model.EtherscanResponse
import io.novafoundation.nova.runtime.multiNetwork.chain.model.ChainId
interface EtherscanTransactionsApi {
suspend fun getErc20Transfers(
chainId: ChainId,
baseUrl: String,
contractAddress: String,
accountAddress: String,
pageNumber: Int,
pageSize: Int
): EtherscanResponse<List<EtherscanAccountTransfer>>
suspend fun getNormalTxsHistory(
chainId: ChainId,
baseUrl: String,
accountAddress: String,
pageNumber: Int,
pageSize: Int
): EtherscanResponse<List<EtherscanNormalTxResponse>>
}
class RealEtherscanTransactionsApi(
private val retrofitApi: RetrofitEtherscanTransactionsApi,
private val apiKeys: EtherscanApiKeys
) : EtherscanTransactionsApi {
override suspend fun getErc20Transfers(
chainId: ChainId,
baseUrl: String,
contractAddress: String,
accountAddress: String,
pageNumber: Int,
pageSize: Int
): EtherscanResponse<List<EtherscanAccountTransfer>> {
val apiKey = apiKeys.keyFor(chainId)
return retrofitApi.getErc20Transfers(
baseUrl = baseUrl,
contractAddress = contractAddress,
accountAddress = accountAddress,
pageNumber = pageNumber,
pageSize = pageSize,
apiKey = apiKey
)
}
override suspend fun getNormalTxsHistory(
chainId: ChainId,
baseUrl: String,
accountAddress: String,
pageNumber: Int,
pageSize: Int
): EtherscanResponse<List<EtherscanNormalTxResponse>> {
val apiKey = apiKeys.keyFor(chainId)
return retrofitApi.getNormalTxsHistory(
baseUrl = baseUrl,
accountAddress = accountAddress,
pageNumber = pageNumber,
pageSize = pageSize,
apiKey = apiKey
)
}
}
@@ -0,0 +1,40 @@
package io.novafoundation.nova.feature_wallet_impl.data.network.etherscan
import io.novafoundation.nova.common.data.network.UserAgent
import io.novafoundation.nova.feature_wallet_impl.data.network.etherscan.model.EtherscanAccountTransfer
import io.novafoundation.nova.feature_wallet_impl.data.network.etherscan.model.EtherscanNormalTxResponse
import io.novafoundation.nova.feature_wallet_impl.data.network.etherscan.model.EtherscanResponse
import retrofit2.http.GET
import retrofit2.http.Headers
import retrofit2.http.Query
import retrofit2.http.Url
interface RetrofitEtherscanTransactionsApi {
@GET
@Headers(UserAgent.NOVA)
suspend fun getErc20Transfers(
@Url baseUrl: String,
@Query("contractaddress") contractAddress: String,
@Query("address") accountAddress: String,
@Query("page") pageNumber: Int,
@Query("offset") pageSize: Int,
@Query("apikey") apiKey: String?,
@Query("module") module: String = "account",
@Query("action") action: String = "tokentx",
@Query("sort") sort: String = "desc"
): EtherscanResponse<List<EtherscanAccountTransfer>>
@GET
@Headers(UserAgent.NOVA)
suspend fun getNormalTxsHistory(
@Url baseUrl: String,
@Query("address") accountAddress: String,
@Query("page") pageNumber: Int,
@Query("offset") pageSize: Int,
@Query("apikey") apiKey: String?,
@Query("module") module: String = "account",
@Query("action") action: String = "txlist",
@Query("sort") sort: String = "desc"
): EtherscanResponse<List<EtherscanNormalTxResponse>>
}
@@ -0,0 +1,13 @@
package io.novafoundation.nova.feature_wallet_impl.data.network.etherscan.model
import java.math.BigInteger
class EtherscanAccountTransfer(
val timeStamp: Long,
val hash: String,
val from: String,
val to: String,
val value: BigInteger,
override val gasPrice: BigInteger,
override val gasUsed: BigInteger,
) : WithEvmFee
@@ -0,0 +1,21 @@
package io.novafoundation.nova.feature_wallet_impl.data.network.etherscan.model
import com.google.gson.annotations.SerializedName
import io.novafoundation.nova.common.utils.removeHexPrefix
import java.math.BigInteger
class EtherscanNormalTxResponse(
val timeStamp: Long,
val hash: String,
val from: String,
val to: String,
val value: BigInteger,
val input: String,
val functionName: String,
@SerializedName("txreceipt_status")val txReceiptStatus: BigInteger,
override val gasPrice: BigInteger,
override val gasUsed: BigInteger,
) : WithEvmFee
val EtherscanNormalTxResponse.isTransfer
get() = input.removeHexPrefix().isEmpty()
@@ -0,0 +1,7 @@
package io.novafoundation.nova.feature_wallet_impl.data.network.etherscan.model
class EtherscanResponse<T>(
val status: String,
val message: String,
val result: T
)
@@ -0,0 +1,13 @@
package io.novafoundation.nova.feature_wallet_impl.data.network.etherscan.model
import java.math.BigInteger
interface WithEvmFee {
val gasPrice: BigInteger
val gasUsed: BigInteger
}
val WithEvmFee.feeUsed
get() = gasUsed * gasPrice
@@ -0,0 +1,33 @@
package io.novafoundation.nova.feature_wallet_impl.data.network.model
import io.novafoundation.nova.runtime.ext.onChainAssetId
import io.novafoundation.nova.runtime.multiNetwork.chain.model.Chain
fun Chain.Asset.Type.subQueryAssetId(): String {
return when (this) {
is Chain.Asset.Type.Equilibrium -> id.toString()
Chain.Asset.Type.Native -> "native"
is Chain.Asset.Type.Orml -> currencyIdScale
is Chain.Asset.Type.Statemine -> id.onChainAssetId()
else -> error("Unsupported assetId type for SubQuery request: ${this::class.simpleName}")
}
}
fun Chain.Asset.Type.isSupportedBySubQuery(): Boolean {
return when (this) {
is Chain.Asset.Type.Equilibrium,
Chain.Asset.Type.Native,
is Chain.Asset.Type.Orml,
is Chain.Asset.Type.Statemine -> true
else -> false
}
}
typealias AssetsBySubQueryId = Map<String?, Chain.Asset>
fun Chain.assetsBySubQueryId(): AssetsBySubQueryId {
return assets
.filter { it.type.isSupportedBySubQuery() }
.associateBy { it.type.subQueryAssetId() }
}
@@ -0,0 +1,197 @@
package io.novafoundation.nova.feature_wallet_impl.data.network.model.request
import io.novafoundation.nova.common.data.network.subquery.SubQueryFilters
import io.novafoundation.nova.common.data.network.subquery.SubqueryExpressions.and
import io.novafoundation.nova.common.data.network.subquery.SubqueryExpressions.anyOf
import io.novafoundation.nova.common.data.network.subquery.SubqueryExpressions.not
import io.novafoundation.nova.common.data.network.subquery.SubqueryExpressions.or
import io.novafoundation.nova.common.utils.nullIfEmpty
import io.novafoundation.nova.feature_wallet_api.domain.interfaces.TransactionFilter
import io.novafoundation.nova.feature_wallet_impl.data.network.model.subQueryAssetId
import io.novafoundation.nova.runtime.ext.StakingTypeGroup
import io.novafoundation.nova.runtime.ext.group
import io.novafoundation.nova.runtime.ext.isSwapSupported
import io.novafoundation.nova.runtime.multiNetwork.chain.model.Chain
import io.novafoundation.nova.runtime.multiNetwork.chain.model.Chain.Asset
private class ModuleRestriction(
val moduleName: String,
val restrictedCalls: List<String>
) {
companion object
}
private fun ModuleRestriction.Companion.ignoreSpecialOperationTypesExtrinsics() = listOf(
ModuleRestriction(
moduleName = "balances",
restrictedCalls = listOf(
"transfer",
"transferKeepAlive",
"transferAllowDeath",
"forceTransfer",
"transferAll"
)
),
ModuleRestriction(
moduleName = "assetConversion",
restrictedCalls = listOf(
"swapExactTokensForTokens",
"swapTokensForExactTokens",
)
)
)
class SubqueryHistoryRequest(
accountAddress: String,
legacyAccountAddress: String?,
pageSize: Int = 1,
cursor: String? = null,
filters: Set<TransactionFilter>,
asset: Asset,
chain: Chain,
) : SubQueryFilters {
val query = """
{
query {
historyElements(
after: ${if (cursor == null) null else "\"$cursor\""},
first: $pageSize,
orderBy: TIMESTAMP_DESC,
filter: {
${addressFilter(accountAddress, legacyAccountAddress)}
${filters.toQueryFilter(asset, chain)}
}
) {
pageInfo {
startCursor,
endCursor
},
nodes {
id
timestamp
extrinsicHash
blockNumber
address
${rewardsResponseSections(asset)}
extrinsic
${transferResponseSection(asset.type)}
${swapResponseSection(chain)}
}
}
}
}
""".trimIndent()
private fun addressFilter(accountAddress: String, legacyAccountAddress: String?): String {
return if (legacyAccountAddress != null) {
"""address: { in: ["$accountAddress", "$legacyAccountAddress"] }"""
} else {
"""address: { equalTo: "$accountAddress" }"""
}
}
private fun Set<TransactionFilter>.toQueryFilter(asset: Asset, chain: Chain): String {
val additionalFilters = not(isIgnoredExtrinsic())
val filtersExpressions = mapNotNull { it.filterExpression(asset, chain) }
val userFilters = anyOf(filtersExpressions)
return userFilters and additionalFilters
}
private fun TransactionFilter.filterExpression(asset: Asset, chain: Chain): String? {
return when (this) {
TransactionFilter.TRANSFER -> transfersFilter(asset.type)
TransactionFilter.REWARD -> rewardsFilter(asset)
TransactionFilter.EXTRINSIC -> hasExtrinsic()
TransactionFilter.SWAP -> swapFilter(chain, asset)
}.nullIfEmpty()
}
private fun transferResponseSection(assetType: Asset.Type): String {
return when (assetType) {
Asset.Type.Native -> "transfer"
else -> "assetTransfer"
}
}
private fun swapResponseSection(chain: Chain): String {
return if (chain.isSwapSupported()) {
"swap"
} else {
""
}
}
private fun rewardsResponseSections(asset: Asset): String {
return rewardsSections(asset).joinToString(separator = "\n")
}
private fun rewardsSections(asset: Asset): List<String> {
return asset.staking.mapNotNull { it.rewardSection() }
}
private fun Asset.StakingType.rewardSection(): String? {
return when (group()) {
StakingTypeGroup.RELAYCHAIN,
StakingTypeGroup.PARACHAIN,
StakingTypeGroup.MYTHOS -> "reward"
StakingTypeGroup.NOMINATION_POOL -> "poolReward"
StakingTypeGroup.UNSUPPORTED -> null
}
}
private fun rewardsFilter(asset: Asset): String {
return anyOf(rewardsSections(asset).map { hasType(it) })
}
private fun transfersFilter(assetType: Asset.Type): String {
return if (assetType == Asset.Type.Native) {
hasType("transfer")
} else {
transferAssetHasId(assetType.subQueryAssetId())
}
}
private fun swapFilter(chain: Chain, asset: Asset): String? {
if (!chain.isSwapSupported()) return null
val subQueryAssetId = asset.type.subQueryAssetId()
return or(
"swap".containsFilter("assetIdIn", subQueryAssetId),
"swap".containsFilter("assetIdOut", subQueryAssetId)
)
}
private fun hasExtrinsic() = hasType("extrinsic")
private fun isIgnoredExtrinsic(): String {
val exists = hasExtrinsic()
val restrictedModulesList = ModuleRestriction.ignoreSpecialOperationTypesExtrinsics().map {
val restrictedCallsExpressions = it.restrictedCalls.map(::callNamed)
and(
moduleNamed(it.moduleName),
anyOf(restrictedCallsExpressions)
)
}
val hasRestrictedModules = anyOf(restrictedModulesList)
return and(
exists,
hasRestrictedModules
)
}
private fun callNamed(callName: String) = "extrinsic: {contains: {call: \"$callName\"}}"
private fun moduleNamed(moduleName: String) = "extrinsic: {contains: {module: \"$moduleName\"}}"
private fun hasType(typeName: String) = "$typeName: {isNull: false}"
private fun transferAssetHasId(assetId: String?): String {
return "assetTransfer".containsFilter("assetId", assetId)
}
}
@@ -0,0 +1,81 @@
package io.novafoundation.nova.feature_wallet_impl.data.network.model.response
import io.novafoundation.nova.feature_wallet_api.data.network.blockhain.types.Balance
import java.math.BigInteger
class SubqueryHistoryElementResponse(val query: Query) {
class Query(val historyElements: HistoryElements) {
class HistoryElements(val nodes: Array<Node>, val pageInfo: PageInfo) {
class PageInfo(
val startCursor: String,
val endCursor: String?
)
class Node(
val id: String,
val timestamp: Long,
val extrinsicHash: String?,
val address: String,
val reward: Reward?,
val blockNumber: Long,
val poolReward: PoolReward?,
val transfer: Transfer?,
val extrinsic: Extrinsic?,
val assetTransfer: AssetTransfer?,
val swap: Swap?
) {
class Reward(
val era: Int?,
val amount: String?,
val eventIdx: Int,
val isReward: Boolean,
val validator: String?,
)
class PoolReward(
val amount: BigInteger,
val eventIdx: Int,
val poolId: Int,
val isReward: Boolean,
)
class Transfer(
val amount: BigInteger,
val to: String,
val from: String,
val fee: BigInteger,
val success: Boolean
)
class Extrinsic(
val module: String,
val call: String,
val fee: BigInteger,
val success: Boolean
)
class AssetTransfer(
val assetId: String,
val amount: BigInteger,
val to: String,
val from: String,
val fee: BigInteger,
val success: Boolean
)
class Swap(
val assetIdIn: String?,
val amountIn: Balance,
val assetIdOut: String?,
val amountOut: Balance,
val sender: String,
val receiver: String,
val fee: Balance,
val assetIdFee: String?,
val success: Boolean?
)
}
}
}
}
@@ -0,0 +1,9 @@
package io.novafoundation.nova.feature_wallet_impl.data.network.phishing
import retrofit2.http.GET
interface PhishingApi {
@GET("https://polkadot.js.org/phishing/address.json")
suspend fun getPhishingAddresses(): Map<String, List<String>>
}
@@ -0,0 +1,17 @@
package io.novafoundation.nova.feature_wallet_impl.data.network.subquery
import io.novafoundation.nova.common.data.network.subquery.SubQueryResponse
import io.novafoundation.nova.feature_wallet_impl.data.network.model.request.SubqueryHistoryRequest
import io.novafoundation.nova.feature_wallet_impl.data.network.model.response.SubqueryHistoryElementResponse
import retrofit2.http.Body
import retrofit2.http.POST
import retrofit2.http.Url
interface SubQueryOperationsApi {
@POST
suspend fun getOperationsHistory(
@Url url: String,
@Body body: SubqueryHistoryRequest
): SubQueryResponse<SubqueryHistoryElementResponse>
}
@@ -0,0 +1,39 @@
package io.novafoundation.nova.feature_wallet_impl.data.repository
import io.novafoundation.nova.common.utils.binarySearchFloor
import io.novafoundation.nova.feature_currency_api.domain.model.Currency
import io.novafoundation.nova.feature_wallet_api.data.source.CoinPriceLocalDataSource
import io.novafoundation.nova.feature_wallet_api.data.source.CoinPriceRemoteDataSource
import io.novafoundation.nova.feature_wallet_api.data.repository.CoinPriceRepository
import io.novafoundation.nova.feature_wallet_api.data.repository.PricePeriod
import io.novafoundation.nova.feature_wallet_api.domain.model.HistoricalCoinRate
import kotlin.time.Duration
class CoinPriceRepositoryImpl(
private val cacheCoinPriceDataSource: CoinPriceLocalDataSource,
private val remoteCoinPriceDataSource: CoinPriceRemoteDataSource
) : CoinPriceRepository {
override suspend fun getCoinPriceAtTime(priceId: String, currency: Currency, timestamp: Duration): HistoricalCoinRate? {
val timestampInSeconds = timestamp.inWholeSeconds
var coinRate = cacheCoinPriceDataSource.getFloorCoinPriceAtTime(priceId, currency, timestampInSeconds)
val hasCeilingItem = cacheCoinPriceDataSource.hasCeilingCoinPriceAtTime(priceId, currency, timestampInSeconds)
if (coinRate == null && !hasCeilingItem) {
val coinRateForAllTime = getLastHistoryForPeriod(priceId, currency, PricePeriod.MAX)
val index = coinRateForAllTime.binarySearchFloor { it.timestamp.compareTo(timestampInSeconds) }
coinRate = coinRateForAllTime.getOrNull(index)
// If nearest coin rate timestamp is bigger than target timestamp it means that coingecko doesn't have data before coin rate timestamp
// so in this case we should return null
if (coinRate != null && coinRate.timestamp > timestampInSeconds) {
return null
}
}
return coinRate
}
override suspend fun getLastHistoryForPeriod(priceId: String, currency: Currency, range: PricePeriod): List<HistoricalCoinRate> {
return remoteCoinPriceDataSource.getLastCoinPriceRange(priceId, currency, range)
}
}
@@ -0,0 +1,72 @@
package io.novafoundation.nova.feature_wallet_impl.data.repository
import android.util.Log
import io.novafoundation.nova.common.utils.LOG_TAG
import io.novafoundation.nova.common.utils.balances
import io.novafoundation.nova.common.utils.getOrNull
import io.novafoundation.nova.common.utils.mapList
import io.novafoundation.nova.core_db.dao.HoldsDao
import io.novafoundation.nova.feature_wallet_api.data.repository.BalanceHoldsRepository
import io.novafoundation.nova.feature_wallet_api.domain.model.BalanceHold
import io.novafoundation.nova.feature_wallet_api.domain.model.mapBalanceHoldFromLocal
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.chainsById
import io.novafoundation.nova.runtime.multiNetwork.getRuntime
import io.novasama.substrate_sdk_android.runtime.definitions.types.composite.DictEnum
import io.novasama.substrate_sdk_android.runtime.definitions.types.composite.Struct
import io.novasama.substrate_sdk_android.runtime.definitions.types.composite.Vec
import io.novasama.substrate_sdk_android.runtime.metadata.storageOrNull
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.map
class RealBalanceHoldsRepository(
private val chainRegistry: ChainRegistry,
private val holdsDao: HoldsDao,
) : BalanceHoldsRepository {
override suspend fun chainHasHoldId(chainId: ChainId, holdId: BalanceHold.HoldId): Boolean {
return runCatching {
val holdReasonType = getHoldReasonType(chainId) ?: return false
holdReasonType.hasHoldId(holdId).also {
Log.d(LOG_TAG, "chainHasHoldId for $chainId: $it")
}
}
.onFailure { Log.w(LOG_TAG, "Failed to get hold reason type", it) }
.getOrDefault(false)
}
override suspend fun observeBalanceHolds(metaInt: Long, chainAsset: Chain.Asset): Flow<List<BalanceHold>> {
return holdsDao.observeBalanceHolds(metaInt, chainAsset.chainId, chainAsset.id).mapList { hold ->
mapBalanceHoldFromLocal(chainAsset, hold)
}
}
override fun observeHoldsForMetaAccount(metaInt: Long): Flow<List<BalanceHold>> {
return holdsDao.observeHoldsForMetaAccount(metaInt).map { holds ->
val chainsById = chainRegistry.chainsById()
holds.mapNotNull { holdLocal ->
val asset = chainsById[holdLocal.chainId]?.assetsById?.get(holdLocal.assetId) ?: return@mapNotNull null
mapBalanceHoldFromLocal(asset, holdLocal)
}
}
}
private fun DictEnum.hasHoldId(holdId: BalanceHold.HoldId): Boolean {
val moduleReasons = getOrNull(holdId.module) as? DictEnum ?: return false
return moduleReasons[holdId.reason] != null
}
private suspend fun getHoldReasonType(chainId: ChainId): DictEnum? {
val runtime = chainRegistry.getRuntime(chainId)
val storage = runtime.metadata.balances().storageOrNull("Holds") ?: return null
val storageReturnType = storage.type.value as Vec
return storageReturnType
.innerType<Struct>()!!
.get<DictEnum>("id")
}
}
@@ -0,0 +1,58 @@
package io.novafoundation.nova.feature_wallet_impl.data.repository
import io.novafoundation.nova.common.utils.mapList
import io.novafoundation.nova.core_db.dao.LockDao
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_wallet_api.data.repository.BalanceLocksRepository
import io.novafoundation.nova.feature_wallet_api.domain.model.BalanceLock
import io.novafoundation.nova.feature_wallet_api.domain.model.BalanceLockId
import io.novafoundation.nova.feature_wallet_api.domain.model.mapBalanceLockFromLocal
import io.novafoundation.nova.runtime.multiNetwork.ChainRegistry
import io.novafoundation.nova.runtime.multiNetwork.chain.model.Chain
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.combine
import kotlinx.coroutines.flow.map
class RealBalanceLocksRepository(
// TODO refactoring - repository should not depend on other repository. MetaId should be passed to repository arguments
private val accountRepository: AccountRepository,
private val chainRegistry: ChainRegistry,
private val lockDao: LockDao
) : BalanceLocksRepository {
override fun observeBalanceLocks(metaId: Long, chain: Chain, chainAsset: Chain.Asset): Flow<List<BalanceLock>> {
return lockDao.observeBalanceLocks(metaId, chain.id, chainAsset.id)
.mapList { lock -> mapBalanceLockFromLocal(chainAsset, lock) }
}
override suspend fun getBalanceLocks(metaId: Long, chainAsset: Chain.Asset): List<BalanceLock> {
return lockDao.getBalanceLocks(metaId, chainAsset.chainId, chainAsset.id)
.map { lock -> mapBalanceLockFromLocal(chainAsset, lock) }
}
override suspend fun getBiggestLock(chain: Chain, chainAsset: Chain.Asset): BalanceLock? {
val metaAccount = accountRepository.getSelectedMetaAccount()
return lockDao.getBiggestBalanceLock(metaAccount.id, chain.id, chainAsset.id)?.let {
mapBalanceLockFromLocal(chainAsset, it)
}
}
override suspend fun observeBalanceLock(chainAsset: Chain.Asset, lockId: BalanceLockId): Flow<BalanceLock?> {
val metaAccount = accountRepository.getSelectedMetaAccount()
return lockDao.observeBalanceLock(metaAccount.id, chainAsset.chainId, chainAsset.id, lockId.value).map { lockLocal ->
lockLocal?.let { mapBalanceLockFromLocal(chainAsset, it) }
}
}
override fun observeLocksForMetaAccount(metaAccount: MetaAccount): Flow<List<BalanceLock>> {
return combine(lockDao.observeLocksForMetaAccount(metaAccount.id), chainRegistry.chainsById) { locks, chains ->
locks.map {
val asset = chains.getValue(it.chainId)
.assetsById.getValue(it.assetId)
mapBalanceLockFromLocal(asset, it)
}
}
}
}
@@ -0,0 +1,31 @@
package io.novafoundation.nova.feature_wallet_impl.data.repository
import com.google.gson.Gson
import io.novafoundation.nova.core_db.dao.ChainAssetDao
import io.novafoundation.nova.core_db.dao.SetAssetEnabledParams
import io.novafoundation.nova.feature_wallet_api.domain.interfaces.ChainAssetRepository
import io.novafoundation.nova.runtime.multiNetwork.chain.mappers.mapChainAssetLocalToAsset
import io.novafoundation.nova.runtime.multiNetwork.chain.mappers.mapChainAssetToLocal
import io.novafoundation.nova.runtime.multiNetwork.chain.model.Chain
import io.novafoundation.nova.runtime.multiNetwork.chain.model.FullChainAssetId
class RealChainAssetRepository(
private val chainAssetDao: ChainAssetDao,
private val gson: Gson
) : ChainAssetRepository {
override suspend fun setAssetsEnabled(enabled: Boolean, assetIds: List<FullChainAssetId>) {
val updateParams = assetIds.map { SetAssetEnabledParams(enabled, it.chainId, it.assetId) }
chainAssetDao.setAssetsEnabled(updateParams)
}
override suspend fun insertCustomAsset(chainAsset: Chain.Asset) {
val localAsset = mapChainAssetToLocal(chainAsset, gson)
chainAssetDao.insertAsset(localAsset)
}
override suspend fun getEnabledAssets(): List<Chain.Asset> {
return chainAssetDao.getEnabledAssets().map { mapChainAssetLocalToAsset(it, gson) }
}
}

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