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
@@ -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 {