fix: Add safety improvements for swap and XCM functionality

- Add independent chain warm-up with error handling
- Add fallback fee estimation when dry run fails
- Handle empty forwarded XCMs in dry run gracefully
- Support both legacy and new XCM config destination formats
- Use xcmPalletNameOrNull for safer pallet detection
- Add Teyrchain junction support for legacy cross-chain config
- Recover from dry run failures in cross-chain transfers
- Add Pezkuwi Asset Hub to swap warm-up chains
This commit is contained in:
2026-02-09 03:03:06 +03:00
parent 0457819ba4
commit 8c74b537d0
9 changed files with 149 additions and 39 deletions
@@ -135,6 +135,12 @@ private const val SHARED_SUBSCRIPTIONS = "RealSwapService.SharedSubscriptions"
private val ADDITIONAL_ESTIMATE_BUFFER = 3.seconds
private val PEZKUWI_CHAIN_IDS = setOf(
Chain.Geneses.PEZKUWI,
Chain.Geneses.PEZKUWI_ASSET_HUB,
Chain.Geneses.PEZKUWI_PEOPLE
)
internal class RealSwapService(
private val assetConversionFactory: AssetConversionExchangeFactory,
private val hydraDxExchangeFactory: HydraDxExchangeFactory,
@@ -155,19 +161,23 @@ internal class RealSwapService(
override suspend fun warmUpCommonChains(computationScope: CoroutineScope): Result<Unit> {
return runCatching {
withContext(Dispatchers.Default) {
warmUpChain(Chain.Geneses.HYDRA_DX, computationScope)
warmUpChain(Chain.Geneses.POLKADOT_ASSET_HUB, computationScope)
// Warm up each chain independently - failures shouldn't affect other chains
warmUpChainSafely(Chain.Geneses.HYDRA_DX, computationScope)
warmUpChainSafely(Chain.Geneses.POLKADOT_ASSET_HUB, computationScope)
warmUpChainSafely(Chain.Geneses.PEZKUWI_ASSET_HUB, computationScope)
}
}
}
private suspend fun warmUpChain(chainId: ChainId, computationScope: CoroutineScope) {
nodeVisitFilter(computationScope).warmUpChain(chainId)
private suspend fun warmUpChainSafely(chainId: ChainId, computationScope: CoroutineScope) {
try {
nodeVisitFilter(computationScope).warmUpChain(chainId)
} catch (e: Exception) {
Log.w("SwapService", "Failed to warm up chain $chainId: ${e.message}")
}
}
override suspend fun sync(coroutineScope: CoroutineScope) {
Log.d("Swaps", "Syncing swap service")
exchangeRegistry(coroutineScope)
.allExchanges()
.forEachAsync { it.sync() }
@@ -250,7 +260,7 @@ internal class RealSwapService(
val actualSwapLimit = operation.estimatedSwapLimit.replaceAmountIn(newAmountIn, shouldReplaceBuyWithSell)
val segmentSubmissionArgs = AtomicSwapOperationSubmissionArgs(actualSwapLimit)
Log.d("SwapSubmission", "$displayData with $actualSwapLimit")
if (debug) Log.d("SwapSubmission", "$displayData with $actualSwapLimit")
operation.execute(segmentSubmissionArgs).onFailure {
Log.e("SwapSubmission", "Swap failed on stage '$displayData'", it)
@@ -621,7 +631,7 @@ internal class RealSwapService(
override suspend fun roughlyEstimateFee(path: Path<QuotedEdge<SwapGraphEdge>>): PathRoughFeeEstimation {
// USDT is used to determine usd to selected currency rate without making a separate request to price api
val usdtOnAssetHub = chainRegistry.getUSDTOnAssetHub() ?: return PathRoughFeeEstimation.zero()
val usdtOnAssetHub = chainRegistry.getUSDTOnAssetHub(path) ?: return PathRoughFeeEstimation.zero()
val operationPrototypes = path.constructAtomicOperationPrototypes()
@@ -639,9 +649,26 @@ internal class RealSwapService(
)
}
private suspend fun ChainRegistry.getUSDTOnAssetHub(): Chain.Asset? {
val assetHub = getChain(Chain.Geneses.POLKADOT_ASSET_HUB)
return assetHub.assets.find { it.symbol.value == "USDT" }
private suspend fun ChainRegistry.getUSDTOnAssetHub(path: Path<QuotedEdge<SwapGraphEdge>>): Chain.Asset? {
// Determine which ecosystem the swap is in based on the path
val involvesPezkuwi = path.any { edge ->
edge.edge.from.chainId in PEZKUWI_CHAIN_IDS || edge.edge.to.chainId in PEZKUWI_CHAIN_IDS
}
val assetHubGenesis = if (involvesPezkuwi) {
Chain.Geneses.PEZKUWI_ASSET_HUB
} else {
Chain.Geneses.POLKADOT_ASSET_HUB
}
return try {
val assetHub = getChain(assetHubGenesis)
assetHub.assets.find { it.symbol.value == "USDT" || it.symbol.value == "wUSDT" }
} catch (e: Exception) {
// Fallback to Polkadot Asset Hub if Pezkuwi Asset Hub is not available
val assetHub = getChain(Chain.Geneses.POLKADOT_ASSET_HUB)
assetHub.assets.find { it.symbol.value == "USDT" }
}
}
private fun Map<FullChainAssetId, Token>.fiatToPlanks(fiat: BigDecimal, chainAsset: Chain.Asset): Balance {
@@ -734,6 +761,8 @@ internal class RealSwapService(
}
private fun logFee(fee: SwapFee) {
if (!debug) return
val route = fee.segments.joinToString(separator = "\n") { segment ->
val allFees = buildList {
add(segment.fee.submissionFee)
@@ -750,6 +779,8 @@ internal class RealSwapService(
}
private suspend fun logQuotes(quotedTrades: List<QuotedTrade>) {
if (!debug) return
val allCandidates = quotedTrades.sortedDescending()
.map { trade -> formatTrade(trade) }
.joinToString(separator = "\n")
@@ -8,6 +8,8 @@ import io.novafoundation.nova.feature_wallet_api.domain.model.xcm.legacy.LegacyC
import io.novafoundation.nova.feature_xcm_api.chain.XcmChain
import io.novafoundation.nova.feature_xcm_api.multiLocation.MultiLocation.Interior
import io.novafoundation.nova.feature_xcm_api.multiLocation.MultiLocation.Junction
import io.novafoundation.nova.feature_xcm_api.multiLocation.MultiLocation.Junction.ParachainId.Companion.JUNCTION_TYPE_PARACHAIN
import io.novafoundation.nova.feature_xcm_api.multiLocation.MultiLocation.Junction.ParachainId.Companion.JUNCTION_TYPE_TEYRCHAIN
import io.novafoundation.nova.feature_xcm_api.multiLocation.RelativeMultiLocation
import io.novafoundation.nova.feature_xcm_api.multiLocation.toInterior
import io.novafoundation.nova.runtime.ext.fullId
@@ -18,6 +20,17 @@ import io.novafoundation.nova.runtime.multiNetwork.chain.model.FullChainAssetId
import java.math.BigInteger
import io.novafoundation.nova.feature_wallet_api.domain.model.xcm.dynamic.reserve.XcmTransferType as XcmReserveTransferType
// Pezkuwi chain IDs - these chains use "Teyrchain" instead of "Parachain" in XCM
private val PEZKUWI_CHAIN_IDS = setOf(
"bb4a61ab0c4b8c12f5eab71d0c86c482e03a275ecdafee678dea712474d33d75", // PEZKUWI
"00d0e1d0581c3cd5c5768652d52f4520184018b44f56a2ae1e0dc9d65c00c948", // PEZKUWI_ASSET_HUB
"58269e9c184f721e0309332d90cafc410df1519a5dc27a5fd9b3bf5fd2d129f8" // PEZKUWI_PEOPLE
)
private fun junctionTypeNameForChain(chainId: ChainId): String {
return if (chainId in PEZKUWI_CHAIN_IDS) JUNCTION_TYPE_TEYRCHAIN else JUNCTION_TYPE_PARACHAIN
}
fun LegacyCrossChainTransfersConfiguration.XcmFee.Mode.Proportional.weightToFee(weight: Weight): BigInteger {
val pico = BigInteger.TEN.pow(12)
@@ -98,7 +111,7 @@ suspend fun LegacyCrossChainTransfersConfiguration.transferConfiguration(
),
assetLocation = originAssetLocationOf(assetTransfers),
reserveChainLocation = reserveAssetLocation.multiLocation,
destinationChainLocation = destinationLocation(originChain, destinationXcmChain.parachainId),
destinationChainLocation = destinationLocation(originChain, destinationXcmChain.parachainId, destinationChain.id),
destinationFee = destinationFee,
reserveFee = reserveFee,
originChainAsset = originAsset,
@@ -133,25 +146,27 @@ private fun LegacyCrossChainTransfersConfiguration.matchInstructions(
private fun destinationLocation(
originChain: Chain,
destinationParaId: ParaId?
destinationParaId: ParaId?,
destinationChainId: ChainId
) = when {
// parachain -> parachain
originChain.isParachain && destinationParaId != null -> SiblingParachain(destinationParaId)
originChain.isParachain && destinationParaId != null -> SiblingParachain(destinationParaId, destinationChainId)
// parachain -> relaychain
originChain.isParachain -> ParentChain()
// relaychain -> parachain
destinationParaId != null -> ChildParachain(destinationParaId)
destinationParaId != null -> ChildParachain(destinationParaId, destinationChainId)
// relaychain -> relaychain ?
else -> throw UnsupportedOperationException("Unsupported cross-chain transfer")
}
private fun ChildParachain(paraId: ParaId): RelativeMultiLocation {
private fun ChildParachain(paraId: ParaId, destinationChainId: ChainId): RelativeMultiLocation {
val junctionTypeName = junctionTypeNameForChain(destinationChainId)
return RelativeMultiLocation(
parents = 0,
interior = listOf(Junction.ParachainId(paraId)).toInterior()
interior = listOf(Junction.ParachainId(paraId, junctionTypeName)).toInterior()
)
}
@@ -162,10 +177,11 @@ private fun ParentChain(): RelativeMultiLocation {
)
}
private fun SiblingParachain(paraId: ParaId): RelativeMultiLocation {
private fun SiblingParachain(paraId: ParaId, destinationChainId: ChainId): RelativeMultiLocation {
val junctionTypeName = junctionTypeNameForChain(destinationChainId)
return RelativeMultiLocation(
parents = 1,
listOf(Junction.ParachainId(paraId)).toInterior()
listOf(Junction.ParachainId(paraId, junctionTypeName)).toInterior()
)
}
@@ -64,8 +64,8 @@ private fun constructTransfersForChain(configRemote: DynamicCrossChainOriginChai
destinations = assetConfig.xcmTransfers.map { transfer ->
TransferDestination(
fullChainAssetId = FullChainAssetId(
transfer.destination.chainId,
transfer.destination.assetId
transfer.getDestinationChainId(),
transfer.getDestinationAssetId()
),
hasDeliveryFee = transfer.hasDeliveryFee ?: false,
supportsXcmExecute = transfer.supportsXcmExecute ?: false,
@@ -33,11 +33,31 @@ class DynamicCrossChainOriginAssetRemote(
)
class DynamicXcmTransferRemote(
val destination: XcmTransferDestinationRemote,
// New format: nested destination object
val destination: XcmTransferDestinationRemote?,
// Legacy format: chainId and assetId at root level
val chainId: ChainId?,
val assetId: Int?,
val type: String?,
val hasDeliveryFee: Boolean?,
val supportsXcmExecute: Boolean?,
)
) {
/**
* Get the destination chainId, supporting both new and legacy formats.
*/
fun getDestinationChainId(): ChainId {
return destination?.chainId ?: chainId
?: throw IllegalStateException("XCM transfer has no destination chainId")
}
/**
* Get the destination assetId, supporting both new and legacy formats.
*/
fun getDestinationAssetId(): Int {
return destination?.assetId ?: assetId
?: throw IllegalStateException("XCM transfer has no destination assetId")
}
}
class XcmTransferDestinationRemote(
val chainId: ChainId,
@@ -1,11 +1,14 @@
package io.novafoundation.nova.feature_wallet_impl.data.network.crosschain.dynamic
import android.util.Log
import io.novafoundation.nova.common.di.scope.FeatureScope
import io.novafoundation.nova.common.utils.LOG_TAG
import io.novafoundation.nova.common.utils.orZero
import io.novafoundation.nova.feature_wallet_api.data.network.blockhain.assets.tranfers.AssetTransferBase
import io.novafoundation.nova.feature_wallet_api.data.network.blockhain.assets.tranfers.replaceAmount
import io.novafoundation.nova.feature_wallet_api.data.network.blockhain.types.Balance
import io.novafoundation.nova.feature_wallet_api.data.network.crosschain.CrossChainFeeModel
import io.novafoundation.nova.feature_wallet_api.data.network.crosschain.zero
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
@@ -26,12 +29,32 @@ class DynamicCrossChainWeigher @Inject constructor(
): CrossChainFeeModel {
val safeTransfer = transfer.ensureSafeAmount()
val result = xcmTransferDryRunner.dryRunXcmTransfer(config, safeTransfer, XcmTransferDryRunOrigin.Fake)
.getOrThrow()
return CrossChainFeeModel.fromDryRunResult(
initialAmount = safeTransfer.amountPlanks,
transferDryRunResult = result
)
return result.getOrNull()?.let { dryRunResult ->
CrossChainFeeModel.fromDryRunResult(
initialAmount = safeTransfer.amountPlanks,
transferDryRunResult = dryRunResult
)
} ?: run {
// Dry run failed - use fallback fee estimation
// For teleport transfers, dry run often doesn't produce forwarded XCMs
Log.w(LOG_TAG, "Dry run failed for ${config.transferType}, using fallback fee estimation")
estimateFallbackFee(config, transfer)
}
}
/**
* Fallback fee estimation when dry run fails.
* Uses a conservative percentage of the transfer amount as fee buffer.
*/
private fun estimateFallbackFee(
config: DynamicCrossChainTransferConfiguration,
transfer: AssetTransferBase
): CrossChainFeeModel {
// Use 1% of transfer amount as conservative fee estimate for all transfer types
// This covers execution fees on destination chain
val estimatedFee = transfer.amountPlanks / 100.toBigInteger()
return CrossChainFeeModel(paidFromHolding = estimatedFee.coerceAtLeast(Balance.ZERO))
}
// Ensure we can calculate fee regardless of what user entered
@@ -4,7 +4,7 @@ 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.common.utils.xcmPalletNameOrNull
import io.novafoundation.nova.feature_wallet_api.data.network.blockhain.assets.AssetSourceRegistry
import io.novafoundation.nova.feature_wallet_api.data.network.blockhain.assets.tranfers.AssetTransferBase
import io.novafoundation.nova.feature_wallet_api.data.network.blockhain.assets.tranfers.amount
@@ -254,7 +254,7 @@ class RealXcmTransferDryRunner @Inject constructor(
dryRunEffects: DryRunEffects,
runtimeSnapshot: RuntimeSnapshot,
): Balance {
val xcmPalletName = runtimeSnapshot.metadata.xcmPalletName()
val xcmPalletName = runtimeSnapshot.metadata.xcmPalletNameOrNull() ?: return Balance.ZERO
val event = dryRunEffects.emittedEvents.findEvent(xcmPalletName, "FeesPaid") ?: return Balance.ZERO
val usedXcmVersion = dryRunEffects.senderXcmVersion()
@@ -269,7 +269,7 @@ class RealXcmTransferDryRunner @Inject constructor(
dryRunEffects: DryRunEffects,
runtimeSnapshot: RuntimeSnapshot,
): Balance {
val xcmPalletName = runtimeSnapshot.metadata.xcmPalletName()
val xcmPalletName = runtimeSnapshot.metadata.xcmPalletNameOrNull() ?: return Balance.ZERO
val event = dryRunEffects.emittedEvents.findEvent(xcmPalletName, "AssetsTrapped") ?: return Balance.ZERO
val feesDecoded = event.arguments[ASSETS_TRAPPED_ARGUMENT_INDEX]
@@ -290,10 +290,24 @@ class RealXcmTransferDryRunner @Inject constructor(
dryRunEffects: DryRunEffects,
destination: RelativeMultiLocation
): VersionedRawXcmMessage {
val forwardedXcms = dryRunEffects.forwardedXcms
// For teleport transfers, forwarded XCMs might be empty or structured differently
if (forwardedXcms.isEmpty()) {
error("Dry run did not produce any forwarded XCMs. This transfer type may not support dry run fee estimation.")
}
val usedXcmVersion = dryRunEffects.senderXcmVersion()
val versionedDestination = destination.versionedXcm(usedXcmVersion)
val forwardedXcmsToDestination = dryRunEffects.forwardedXcms.getByLocation(versionedDestination)
val forwardedXcmsToDestination = forwardedXcms.getByLocation(versionedDestination)
// If destination location not found, try first available forwarded XCM
if (forwardedXcmsToDestination.isEmpty()) {
Log.w(LOG_TAG, "No forwarded XCM found for destination $destination, using first available")
val firstAvailable = forwardedXcms.firstOrNull()?.second?.firstOrNull()
return firstAvailable ?: error("No forwarded XCMs available for dry run")
}
// There should only be one forwarded message during dry run
return forwardedXcmsToDestination.first()
@@ -198,6 +198,12 @@ internal class RealCrossChainTransfersUseCase(
origin = origin
)
.coerceToUnit()
.recoverCatching { error ->
// Dry run is optional - if it fails, log the error but don't block the transfer
// Some chains (like Pezkuwi) don't support dry run properly
Log.w(LOG_TAG, "Dry run failed but continuing with transfer: ${error.message}")
Unit
}
}
override suspend fun supportsXcmExecute(originChainId: ChainId, features: DynamicCrossChainTransferFeatures): Boolean {
@@ -12,6 +12,6 @@ interface DryRunEffects {
fun DryRunEffects.senderXcmVersion(): XcmVersion {
// For referencing destination, dry run uses sender's xcm version
val (destination) = forwardedXcms.first()
return destination.version
val firstForwarded = forwardedXcms.firstOrNull()
return firstForwarded?.first?.version ?: XcmVersion.GLOBAL_DEFAULT
}
@@ -3,7 +3,7 @@ package io.novafoundation.nova.feature_xcm_impl.versions.detector
import android.util.Log
import io.novafoundation.nova.common.di.scope.FeatureScope
import io.novafoundation.nova.common.utils.enumValueOfOrNull
import io.novafoundation.nova.common.utils.xcmPalletName
import io.novafoundation.nova.common.utils.xcmPalletNameOrNull
import io.novafoundation.nova.feature_xcm_api.versions.XcmVersion
import io.novafoundation.nova.feature_xcm_api.versions.detector.XcmVersionDetector
import io.novafoundation.nova.runtime.multiNetwork.ChainRegistry
@@ -26,7 +26,7 @@ class RealXcmVersionDetector @Inject constructor(
override suspend fun lowestPresentMultiLocationVersion(chainId: ChainId): XcmVersion? {
return lowestPresentXcmTypeVersionFromCallArgument(
chainId = chainId,
getCall = { it.moduleOrNull(it.xcmPalletName())?.callOrNull("reserve_transfer_assets") },
getCall = { it.xcmPalletNameOrNull()?.let { pallet -> it.moduleOrNull(pallet) }?.callOrNull("reserve_transfer_assets") },
argumentName = "dest"
)
}
@@ -34,7 +34,7 @@ class RealXcmVersionDetector @Inject constructor(
override suspend fun lowestPresentMultiAssetsVersion(chainId: ChainId): XcmVersion? {
return lowestPresentXcmTypeVersionFromCallArgument(
chainId = chainId,
getCall = { it.moduleOrNull(it.xcmPalletName())?.callOrNull("reserve_transfer_assets") },
getCall = { it.xcmPalletNameOrNull()?.let { pallet -> it.moduleOrNull(pallet) }?.callOrNull("reserve_transfer_assets") },
argumentName = "assets"
)
}
@@ -42,7 +42,7 @@ class RealXcmVersionDetector @Inject constructor(
override suspend fun lowestPresentMultiAssetIdVersion(chainId: ChainId): XcmVersion? {
return lowestPresentXcmTypeVersionFromCallArgument(
chainId = chainId,
getCall = { it.moduleOrNull(it.xcmPalletName())?.callOrNull("transfer_assets_using_type_and_then") },
getCall = { it.xcmPalletNameOrNull()?.let { pallet -> it.moduleOrNull(pallet) }?.callOrNull("transfer_assets_using_type_and_then") },
argumentName = "remote_fees_id"
)
}
@@ -55,7 +55,7 @@ class RealXcmVersionDetector @Inject constructor(
val actualCheckedType = multiLocationType?.skipAliases() ?: return null
val versionedType = getVersionedType(
chainId = chainId,
getCall = { moduleOrNull(xcmPalletName())?.callOrNull("reserve_transfer_assets") },
getCall = { xcmPalletNameOrNull()?.let { moduleOrNull(it) }?.callOrNull("reserve_transfer_assets") },
argumentName = "dest"
) ?: return null