Initial commit: Pezkuwi Wallet Android

Complete rebrand of Nova Wallet for Pezkuwichain ecosystem.

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

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

Based on Nova Wallet by Novasama Technologies GmbH
© Dijital Kurdistan Tech Institute 2026
This commit is contained in:
2026-01-23 01:31:12 +03:00
commit 31c8c5995f
7621 changed files with 425838 additions and 0 deletions
@@ -0,0 +1,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,80 @@
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.chainId, transfer.assetId),
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,173 @@
package io.novafoundation.nova.feature_wallet_impl.data.network.blockchain.assets.balances.statemine
import io.novafoundation.nova.common.data.network.runtime.binding.AccountBalance
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.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 chainAsset.requireStatemine().isSufficient
}
override suspend fun existentialDeposit(chainAsset: Chain.Asset): BigInteger {
return queryAssetDetails(chainAsset).minimumBalance
}
override suspend fun queryAccountBalance(chain: Chain, chainAsset: Chain.Asset, accountId: AccountId): ChainAssetBalance {
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()
return ChainAssetBalance.default(chainAsset, accountBalance)
}
override suspend fun subscribeAccountBalanceUpdatePoint(
chain: Chain,
chainAsset: Chain.Asset,
accountId: AccountId,
): Flow<TransferableBalanceUpdatePoint> {
val statemineType = chainAsset.requireStatemine()
return remoteStorage.subscribe(chain.id) {
val encodableId = statemineType.prepareIdForEncoding(runtime)
runtime.metadata.statemineModule(statemineType).storage("Account").observeWithRaw(
encodableId,
accountId,
binding = ::bindAssetAccountOrEmpty
).map {
TransferableBalanceUpdatePoint(it.at!!)
}
}
}
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> {
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)
return 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
}
}
}
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,184 @@
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.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 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)
}
}
}
override suspend fun startSyncingBalanceHolds(
metaAccount: MetaAccount,
chain: Chain,
chainAsset: Chain.Asset,
accountId: AccountId,
subscriptionBuilder: SharedRequestsBuilder
): Flow<*> {
val runtime = chainRegistry.getRuntime(chain.id)
val storage = runtime.metadata.balances().storageOrNull("Holds") ?: return emptyFlow<Nothing>()
val key = storage.storageKey(runtime, accountId)
return subscriptionBuilder.subscribe(key)
.map { change ->
val holds = bindBalanceHolds(storage.decodeValue(change.value, runtime)).orEmpty()
holdsDao.updateHolds(holds, metaAccount.id, chain.id, chainAsset.id)
}
}
override fun isSelfSufficient(chainAsset: Chain.Asset): Boolean {
return true
}
override suspend fun existentialDeposit(chainAsset: Chain.Asset): BigInteger {
val runtime = chainRegistry.getRuntime(chainAsset.chainId)
return runtime.metadata.balances().numberConstant("ExistentialDeposit", runtime)
}
override suspend fun queryAccountBalance(chain: Chain, chainAsset: Chain.Asset, accountId: AccountId): ChainAssetBalance {
return accountInfoRepository.getAccountInfo(chain.id, accountId).data.toChainAssetBalance(chainAsset)
}
override suspend fun subscribeAccountBalanceUpdatePoint(
chain: Chain,
chainAsset: Chain.Asset,
accountId: AccountId,
): Flow<TransferableBalanceUpdatePoint> {
return remoteStorage.subscribe(chain.id) {
metadata.system.account.observeWithRaw(accountId).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)
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
}
}
}
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.novasama.substrate_sdk_android.runtime.definitions.types.instances.AddressInstanceConstructor
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 AddressInstanceConstructor.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.novasama.substrate_sdk_android.runtime.definitions.types.instances.AddressInstanceConstructor
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 AddressInstanceConstructor.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,104 @@
package io.novafoundation.nova.feature_wallet_impl.data.network.crosschain.common
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)
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,40 @@
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(
val chainId: ChainId,
val assetId: Int,
val hasDeliveryFee: Boolean?,
val supportsXcmExecute: Boolean?,
)
@@ -0,0 +1,252 @@
package io.novafoundation.nova.feature_wallet_impl.data.network.crosschain.dynamic
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
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,62 @@
package io.novafoundation.nova.feature_wallet_impl.data.network.crosschain.dynamic
import io.novafoundation.nova.common.di.scope.FeatureScope
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)
.getOrThrow()
return CrossChainFeeModel.fromDryRunResult(
initialAmount = safeTransfer.amountPlanks,
transferDryRunResult = result
)
}
// 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,333 @@
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.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.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.xcmPalletName()
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.xcmPalletName()
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 usedXcmVersion = dryRunEffects.senderXcmVersion()
val versionedDestination = destination.versionedXcm(usedXcmVersion)
val forwardedXcmsToDestination = dryRunEffects.forwardedXcms.getByLocation(versionedDestination)
// 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.novasama.substrate_sdk_android.runtime.definitions.types.instances.AddressInstanceConstructor
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 AddressInstanceConstructor.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.novasama.substrate_sdk_android.runtime.definitions.types.instances.AddressInstanceConstructor
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 AddressInstanceConstructor.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.novasama.substrate_sdk_android.runtime.definitions.types.instances.AddressInstanceConstructor
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 AddressInstanceConstructor.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) }
}
}
@@ -0,0 +1,68 @@
package io.novafoundation.nova.feature_wallet_impl.data.repository
import com.google.gson.Gson
import io.novafoundation.nova.common.interfaces.FileCache
import io.novafoundation.nova.common.utils.fromJson
import io.novafoundation.nova.common.utils.retryUntilDone
import io.novafoundation.nova.feature_wallet_api.data.network.crosschain.CrossChainTransfersRepository
import io.novafoundation.nova.feature_wallet_api.domain.model.xcm.CrossChainTransfersConfiguration
import io.novafoundation.nova.feature_wallet_impl.data.mappers.crosschain.toDomain
import io.novafoundation.nova.feature_wallet_impl.data.network.crosschain.CrossChainConfigApi
import io.novafoundation.nova.feature_wallet_impl.data.network.crosschain.dynamic.DynamicCrossChainTransfersConfigRemote
import io.novafoundation.nova.feature_wallet_impl.data.network.crosschain.legacy.LegacyCrossChainTransfersConfigRemote
import io.novafoundation.nova.runtime.repository.ParachainInfoRepository
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Deferred
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.async
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.first
import kotlinx.coroutines.flow.map
import kotlinx.coroutines.flow.zip
import kotlinx.coroutines.withContext
private const val LEGACY_CACHE_NAME = "RealCrossChainTransfersRepository.CrossChainConfig"
private const val DYNAMIC_CACHE_NAME = "RealCrossChainTransfersRepository.DynamicCrossChainConfig"
class RealCrossChainTransfersRepository(
private val api: CrossChainConfigApi,
private val fileCache: FileCache,
private val gson: Gson,
private val parachainInfoRepository: ParachainInfoRepository,
) : CrossChainTransfersRepository {
override suspend fun syncConfiguration() = withContext(Dispatchers.IO) {
val legacy = syncConfiguration(LEGACY_CACHE_NAME) { api.getLegacyCrossChainConfig() }
val dynamic = syncConfiguration(DYNAMIC_CACHE_NAME) { api.getDynamicCrossChainConfig() }
legacy.await()
dynamic.await()
}
override fun configurationFlow(): Flow<CrossChainTransfersConfiguration> {
val legacyFlow = fileCache.observeCachedValue(LEGACY_CACHE_NAME).map {
val remote = gson.fromJson<LegacyCrossChainTransfersConfigRemote>(it)
remote.toDomain(parachainInfoRepository)
}
val dynamicFlow = fileCache.observeCachedValue(DYNAMIC_CACHE_NAME).map {
val remote = gson.fromJson<DynamicCrossChainTransfersConfigRemote>(it)
remote.toDomain(parachainInfoRepository)
}
return dynamicFlow.zip(legacyFlow, ::CrossChainTransfersConfiguration)
}
override suspend fun getConfiguration(): CrossChainTransfersConfiguration {
return withContext(Dispatchers.Default) {
configurationFlow().first()
}
}
private fun CoroutineScope.syncConfiguration(cacheFileName: String, load: suspend () -> String): Deferred<Unit> {
return async {
val raw = retryUntilDone { load() }
fileCache.updateCache(cacheFileName, raw)
}
}
}
@@ -0,0 +1,46 @@
package io.novafoundation.nova.feature_wallet_impl.data.repository
import io.novafoundation.nova.common.utils.mapList
import io.novafoundation.nova.core_db.dao.ExternalBalanceAssetDeleteParams
import io.novafoundation.nova.core_db.dao.ExternalBalanceDao
import io.novafoundation.nova.core_db.model.AggregatedExternalBalanceLocal
import io.novafoundation.nova.core_db.model.ExternalBalanceLocal
import io.novafoundation.nova.feature_wallet_api.data.repository.ExternalBalanceRepository
import io.novafoundation.nova.feature_wallet_api.domain.model.ExternalBalance
import io.novafoundation.nova.runtime.multiNetwork.chain.model.FullChainAssetId
import kotlinx.coroutines.flow.Flow
internal class RealExternalBalanceRepository(
private val externalBalanceDao: ExternalBalanceDao,
) : ExternalBalanceRepository {
override fun observeAccountExternalBalances(metaId: Long): Flow<List<ExternalBalance>> {
return externalBalanceDao.observeAggregatedExternalBalances(metaId).mapList(::mapExternalBalanceFromLocal)
}
override fun observeAccountChainExternalBalances(metaId: Long, assetId: FullChainAssetId): Flow<List<ExternalBalance>> {
return externalBalanceDao.observeChainAggregatedExternalBalances(metaId, assetId.chainId, assetId.assetId)
.mapList(::mapExternalBalanceFromLocal)
}
override suspend fun deleteExternalBalances(assetIds: List<FullChainAssetId>) {
val params = assetIds.map { ExternalBalanceAssetDeleteParams(it.chainId, it.assetId) }
return externalBalanceDao.deleteAssetExternalBalances(params)
}
private fun mapExternalBalanceFromLocal(externalBalance: AggregatedExternalBalanceLocal): ExternalBalance {
return ExternalBalance(
chainAssetId = FullChainAssetId(externalBalance.chainId, externalBalance.assetId),
amount = externalBalance.aggregatedAmount,
type = mapExternalBalanceTypeFromLocal(externalBalance.type)
)
}
private fun mapExternalBalanceTypeFromLocal(local: ExternalBalanceLocal.Type): ExternalBalance.Type {
return when (local) {
ExternalBalanceLocal.Type.CROWDLOAN -> ExternalBalance.Type.CROWDLOAN
ExternalBalanceLocal.Type.NOMINATION_POOL -> ExternalBalance.Type.NOMINATION_POOL
}
}
}

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