fix: resolve staking reward calculation for parachain (Asset Hub)

- Use remote RPC instead of local storage for era/exposure queries
- Resolve relay chain via parentId for exposure, totalIssuance, and parachains
- Remove StorageCache dependency, detect paged exposures via runtime metadata
- Add StakingRepository to NominationPoolRewardCalculatorFactory for direct queries
- Use storageOrNull for null-safety on chains without Paras pallet
This commit is contained in:
2026-02-14 05:38:47 +03:00
parent 921e6de224
commit c461e61895
7 changed files with 57 additions and 28 deletions
@@ -6,7 +6,7 @@ import io.novafoundation.nova.common.data.network.runtime.binding.bindNumber
import io.novafoundation.nova.common.utils.parasOrNull import io.novafoundation.nova.common.utils.parasOrNull
import io.novafoundation.nova.runtime.multiNetwork.chain.model.ChainId import io.novafoundation.nova.runtime.multiNetwork.chain.model.ChainId
import io.novafoundation.nova.runtime.storage.source.StorageDataSource import io.novafoundation.nova.runtime.storage.source.StorageDataSource
import io.novasama.substrate_sdk_android.runtime.metadata.storage import io.novasama.substrate_sdk_android.runtime.metadata.storageOrNull
interface ParasRepository { interface ParasRepository {
@@ -21,7 +21,7 @@ class RealParasRepository(
override suspend fun activePublicParachains(chainId: ChainId): Int? { override suspend fun activePublicParachains(chainId: ChainId): Int? {
return localSource.query(chainId) { return localSource.query(chainId) {
val parachains = runtime.metadata.parasOrNull()?.storage("Parachains") val parachains = runtime.metadata.parasOrNull()?.storageOrNull("Parachains")
?.query(binding = ::bindParachains) ?: return@query null ?.query(binding = ::bindParachains) ?: return@query null
parachains.count { it >= LOWEST_PUBLIC_ID } parachains.count { it >= LOWEST_PUBLIC_ID }
@@ -13,7 +13,6 @@ import io.novafoundation.nova.common.utils.metadata
import io.novafoundation.nova.common.utils.numberConstant import io.novafoundation.nova.common.utils.numberConstant
import io.novafoundation.nova.common.utils.numberConstantOrNull import io.novafoundation.nova.common.utils.numberConstantOrNull
import io.novafoundation.nova.common.utils.staking import io.novafoundation.nova.common.utils.staking
import io.novafoundation.nova.core.storage.StorageCache
import io.novafoundation.nova.core_db.dao.AccountStakingDao import io.novafoundation.nova.core_db.dao.AccountStakingDao
import io.novafoundation.nova.core_db.model.AccountStakingLocal import io.novafoundation.nova.core_db.model.AccountStakingLocal
import io.novafoundation.nova.feature_account_api.data.model.AccountIdMap import io.novafoundation.nova.feature_account_api.data.model.AccountIdMap
@@ -50,7 +49,6 @@ import io.novafoundation.nova.feature_staking_impl.data.network.blockhain.bindin
import io.novafoundation.nova.feature_staking_impl.data.network.blockhain.bindings.bindSlashDeferDuration import io.novafoundation.nova.feature_staking_impl.data.network.blockhain.bindings.bindSlashDeferDuration
import io.novafoundation.nova.feature_staking_impl.data.network.blockhain.bindings.bindSlashingSpans import io.novafoundation.nova.feature_staking_impl.data.network.blockhain.bindings.bindSlashingSpans
import io.novafoundation.nova.feature_staking_impl.data.network.blockhain.bindings.bindValidatorPrefs import io.novafoundation.nova.feature_staking_impl.data.network.blockhain.bindings.bindValidatorPrefs
import io.novafoundation.nova.feature_staking_impl.data.network.blockhain.updaters.ValidatorExposureUpdater
import io.novafoundation.nova.feature_staking_impl.data.network.blockhain.updaters.activeEraStorageKey import io.novafoundation.nova.feature_staking_impl.data.network.blockhain.updaters.activeEraStorageKey
import io.novafoundation.nova.feature_wallet_api.domain.interfaces.WalletConstants import io.novafoundation.nova.feature_wallet_api.domain.interfaces.WalletConstants
import io.novafoundation.nova.runtime.call.MultiChainRuntimeCallsApi import io.novafoundation.nova.runtime.call.MultiChainRuntimeCallsApi
@@ -88,7 +86,6 @@ class StakingRepositoryImpl(
private val localStorage: StorageDataSource, private val localStorage: StorageDataSource,
private val walletConstants: WalletConstants, private val walletConstants: WalletConstants,
private val chainRegistry: ChainRegistry, private val chainRegistry: ChainRegistry,
private val storageCache: StorageCache,
private val multiChainRuntimeCallsApi: MultiChainRuntimeCallsApi, private val multiChainRuntimeCallsApi: MultiChainRuntimeCallsApi,
) : StakingRepository { ) : StakingRepository {
@@ -127,7 +124,7 @@ class StakingRepositoryImpl(
return runtime.metadata.staking().numberConstant("SessionsPerEra", runtime) // How many sessions per era return runtime.metadata.staking().numberConstant("SessionsPerEra", runtime) // How many sessions per era
} }
override suspend fun getActiveEraIndex(chainId: ChainId): EraIndex = localStorage.query(chainId) { override suspend fun getActiveEraIndex(chainId: ChainId): EraIndex = remoteStorage.query(chainId) {
metadata.staking.activeEra.queryNonNull() metadata.staking.activeEra.queryNonNull()
} }
@@ -164,7 +161,7 @@ class StakingRepositoryImpl(
} }
} }
private suspend fun fetchPagedEraStakers(chainId: ChainId, eraIndex: EraIndex): AccountIdMap<Exposure> = localStorage.query(chainId) { private suspend fun fetchPagedEraStakers(chainId: ChainId, eraIndex: EraIndex): AccountIdMap<Exposure> = remoteStorage.query(chainId) {
val eraStakersOverview = metadata.staking().storage("ErasStakersOverview").entries( val eraStakersOverview = metadata.staking().storage("ErasStakersOverview").entries(
eraIndex, eraIndex,
keyExtractor = { (_: BigInteger, accountId: ByteArray) -> accountId.toHexString() }, keyExtractor = { (_: BigInteger, accountId: ByteArray) -> accountId.toHexString() },
@@ -207,7 +204,7 @@ class StakingRepositoryImpl(
} }
} }
private suspend fun fetchLegacyEraStakers(chainId: ChainId, eraIndex: EraIndex): AccountIdMap<Exposure> = localStorage.query(chainId) { private suspend fun fetchLegacyEraStakers(chainId: ChainId, eraIndex: EraIndex): AccountIdMap<Exposure> = remoteStorage.query(chainId) {
runtime.metadata.staking().storage("ErasStakers").entries( runtime.metadata.staking().storage("ErasStakers").entries(
eraIndex, eraIndex,
keyExtractor = { (_: BigInteger, accountId: ByteArray) -> accountId.toHexString() }, keyExtractor = { (_: BigInteger, accountId: ByteArray) -> accountId.toHexString() },
@@ -405,9 +402,8 @@ class StakingRepositoryImpl(
} }
private suspend fun isPagedExposuresUsed(chainId: ChainId): Boolean { private suspend fun isPagedExposuresUsed(chainId: ChainId): Boolean {
val isPagedExposuresValue = storageCache.getEntry(ValidatorExposureUpdater.STORAGE_KEY_PAGED_EXPOSURES, chainId) val runtime = runtimeFor(chainId)
return runtime.metadata.staking().storageOrNull("ErasStakersOverview") != null
return ValidatorExposureUpdater.decodeIsPagedExposuresValue(isPagedExposuresValue.content)
} }
private fun observeAccountValidatorPrefs(chainId: ChainId, stashId: AccountId): Flow<ValidatorPrefs?> { private fun observeAccountValidatorPrefs(chainId: ChainId, stashId: AccountId): Flow<ValidatorPrefs?> {
@@ -11,7 +11,6 @@ import io.novafoundation.nova.common.data.network.rpc.BulkRetriever
import io.novafoundation.nova.common.di.scope.FeatureScope import io.novafoundation.nova.common.di.scope.FeatureScope
import io.novafoundation.nova.common.presentation.AssetIconProvider import io.novafoundation.nova.common.presentation.AssetIconProvider
import io.novafoundation.nova.common.resources.ResourceManager import io.novafoundation.nova.common.resources.ResourceManager
import io.novafoundation.nova.core.storage.StorageCache
import io.novafoundation.nova.core_db.dao.AccountStakingDao import io.novafoundation.nova.core_db.dao.AccountStakingDao
import io.novafoundation.nova.core_db.dao.ExternalBalanceDao import io.novafoundation.nova.core_db.dao.ExternalBalanceDao
import io.novafoundation.nova.core_db.dao.StakingRewardPeriodDao import io.novafoundation.nova.core_db.dao.StakingRewardPeriodDao
@@ -190,7 +189,6 @@ class StakingFeatureModule {
@Named(REMOTE_STORAGE_SOURCE) remoteStorageSource: StorageDataSource, @Named(REMOTE_STORAGE_SOURCE) remoteStorageSource: StorageDataSource,
walletConstants: WalletConstants, walletConstants: WalletConstants,
chainRegistry: ChainRegistry, chainRegistry: ChainRegistry,
storageCache: StorageCache,
multiChainRuntimeCallsApi: MultiChainRuntimeCallsApi multiChainRuntimeCallsApi: MultiChainRuntimeCallsApi
): StakingRepository = StakingRepositoryImpl( ): StakingRepository = StakingRepositoryImpl(
accountStakingDao = accountStakingDao, accountStakingDao = accountStakingDao,
@@ -198,7 +196,6 @@ class StakingFeatureModule {
localStorage = localStorageSource, localStorage = localStorageSource,
walletConstants = walletConstants, walletConstants = walletConstants,
chainRegistry = chainRegistry, chainRegistry = chainRegistry,
storageCache = storageCache,
multiChainRuntimeCallsApi = multiChainRuntimeCallsApi multiChainRuntimeCallsApi = multiChainRuntimeCallsApi
) )
@@ -26,6 +26,7 @@ import io.novafoundation.nova.feature_staking_impl.data.nominationPools.reposito
import io.novafoundation.nova.feature_staking_impl.data.nominationPools.repository.RealNominationPoolMembersRepository import io.novafoundation.nova.feature_staking_impl.data.nominationPools.repository.RealNominationPoolMembersRepository
import io.novafoundation.nova.feature_staking_impl.data.nominationPools.repository.RealNominationPoolStateRepository import io.novafoundation.nova.feature_staking_impl.data.nominationPools.repository.RealNominationPoolStateRepository
import io.novafoundation.nova.feature_staking_impl.data.nominationPools.repository.RealNominationPoolUnbondRepository import io.novafoundation.nova.feature_staking_impl.data.nominationPools.repository.RealNominationPoolUnbondRepository
import io.novafoundation.nova.feature_staking_api.domain.api.StakingRepository
import io.novafoundation.nova.feature_staking_impl.data.repository.StakingConstantsRepository import io.novafoundation.nova.feature_staking_impl.data.repository.StakingConstantsRepository
import io.novafoundation.nova.feature_staking_impl.data.repository.StakingRewardsRepository import io.novafoundation.nova.feature_staking_impl.data.repository.StakingRewardsRepository
import io.novafoundation.nova.feature_staking_impl.domain.StakingInteractor import io.novafoundation.nova.feature_staking_impl.domain.StakingInteractor
@@ -186,10 +187,12 @@ class NominationPoolModule {
fun provideNominationPoolRewardCalculatorFactory( fun provideNominationPoolRewardCalculatorFactory(
stakingSharedComputation: StakingSharedComputation, stakingSharedComputation: StakingSharedComputation,
nominationPoolSharedComputation: NominationPoolSharedComputation, nominationPoolSharedComputation: NominationPoolSharedComputation,
stakingRepository: StakingRepository,
): NominationPoolRewardCalculatorFactory { ): NominationPoolRewardCalculatorFactory {
return NominationPoolRewardCalculatorFactory( return NominationPoolRewardCalculatorFactory(
sharedStakingSharedComputation = stakingSharedComputation, sharedStakingSharedComputation = stakingSharedComputation,
nominationPoolSharedComputation = nominationPoolSharedComputation nominationPoolSharedComputation = nominationPoolSharedComputation,
stakingRepository = stakingRepository
) )
} }
@@ -11,12 +11,12 @@ import io.novafoundation.nova.common.utils.reversed
import io.novafoundation.nova.feature_account_api.data.model.AccountIdKeyMap import io.novafoundation.nova.feature_account_api.data.model.AccountIdKeyMap
import io.novafoundation.nova.feature_account_api.data.model.AccountIdMap import io.novafoundation.nova.feature_account_api.data.model.AccountIdMap
import io.novafoundation.nova.feature_staking_api.domain.model.Exposure import io.novafoundation.nova.feature_staking_api.domain.model.Exposure
import io.novafoundation.nova.feature_staking_api.domain.api.StakingRepository
import io.novafoundation.nova.feature_staking_impl.data.StakingOption import io.novafoundation.nova.feature_staking_impl.data.StakingOption
import io.novafoundation.nova.feature_staking_impl.data.chain import io.novafoundation.nova.feature_staking_impl.data.chain
import io.novafoundation.nova.feature_staking_api.domain.nominationPool.model.PoolId import io.novafoundation.nova.feature_staking_api.domain.nominationPool.model.PoolId
import io.novafoundation.nova.feature_staking_impl.data.unwrapNominationPools import io.novafoundation.nova.feature_staking_impl.data.unwrapNominationPools
import io.novafoundation.nova.feature_staking_impl.domain.common.StakingSharedComputation import io.novafoundation.nova.feature_staking_impl.domain.common.StakingSharedComputation
import io.novafoundation.nova.feature_staking_impl.domain.common.electedExposuresInActiveEra
import io.novafoundation.nova.feature_staking_impl.domain.nominationPools.common.NominationPoolSharedComputation import io.novafoundation.nova.feature_staking_impl.domain.nominationPools.common.NominationPoolSharedComputation
import io.novafoundation.nova.feature_staking_impl.domain.rewards.RewardCalculator import io.novafoundation.nova.feature_staking_impl.domain.rewards.RewardCalculator
import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.CoroutineScope
@@ -24,21 +24,33 @@ import kotlinx.coroutines.CoroutineScope
class NominationPoolRewardCalculatorFactory( class NominationPoolRewardCalculatorFactory(
private val sharedStakingSharedComputation: StakingSharedComputation, private val sharedStakingSharedComputation: StakingSharedComputation,
private val nominationPoolSharedComputation: NominationPoolSharedComputation, private val nominationPoolSharedComputation: NominationPoolSharedComputation,
private val stakingRepository: StakingRepository,
) { ) {
suspend fun create(stakingOption: StakingOption, sharedComputationScope: CoroutineScope): NominationPoolRewardCalculator { suspend fun create(stakingOption: StakingOption, sharedComputationScope: CoroutineScope): NominationPoolRewardCalculator {
val chainId = stakingOption.chain.id val chain = stakingOption.chain
val chainId = chain.id
// For parachains, staking exposures live on the parent relay chain
val exposureChainId = chain.parentId ?: chainId
android.util.Log.d("PEZ_STAKING", "NomPoolRewardCalcFactory.create() chainId=${chainId.take(12)} exposureChainId=${exposureChainId.take(12)}")
val delegateOption = stakingOption.unwrapNominationPools() val delegateOption = stakingOption.unwrapNominationPools()
val delegate = sharedStakingSharedComputation.rewardCalculator(delegateOption, sharedComputationScope) val delegate = sharedStakingSharedComputation.rewardCalculator(delegateOption, sharedComputationScope)
val allPoolAccounts = nominationPoolSharedComputation.allBondedPoolAccounts(chainId, sharedComputationScope) val allPoolAccounts = nominationPoolSharedComputation.allBondedPoolAccounts(chainId, sharedComputationScope)
android.util.Log.d("PEZ_STAKING", "Pool accounts: ${allPoolAccounts.size}")
val poolCommissions = nominationPoolSharedComputation.allBondedPools(chainId, sharedComputationScope) val poolCommissions = nominationPoolSharedComputation.allBondedPools(chainId, sharedComputationScope)
.mapValues { (_, pool) -> pool.commission?.current?.perbill } .mapValues { (_, pool) -> pool.commission?.current?.perbill }
val activeEra = stakingRepository.getActiveEraIndex(exposureChainId)
val exposures = stakingRepository.getElectedValidatorsExposure(exposureChainId, activeEra)
android.util.Log.d("PEZ_STAKING", "NomPool exposures: ${exposures.size} (era=$activeEra)")
return RealNominationPoolRewardCalculator( return RealNominationPoolRewardCalculator(
directStakingDelegate = delegate, directStakingDelegate = delegate,
exposures = sharedStakingSharedComputation.electedExposuresInActiveEra(stakingOption.assetWithChain.chain.id, sharedComputationScope), exposures = exposures,
commissions = poolCommissions, commissions = poolCommissions,
poolStashesById = allPoolAccounts poolStashesById = allPoolAccounts
) )
@@ -13,7 +13,6 @@ import io.novafoundation.nova.feature_staking_impl.data.repository.VaraRepositor
import io.novafoundation.nova.feature_staking_impl.data.stakingType import io.novafoundation.nova.feature_staking_impl.data.stakingType
import io.novafoundation.nova.feature_staking_impl.data.unwrapNominationPools import io.novafoundation.nova.feature_staking_impl.data.unwrapNominationPools
import io.novafoundation.nova.feature_staking_impl.domain.common.StakingSharedComputation import io.novafoundation.nova.feature_staking_impl.domain.common.StakingSharedComputation
import io.novafoundation.nova.feature_staking_impl.domain.common.electedExposuresInActiveEra
import io.novafoundation.nova.feature_staking_impl.domain.common.eraTimeCalculator import io.novafoundation.nova.feature_staking_impl.domain.common.eraTimeCalculator
import io.novafoundation.nova.feature_staking_impl.domain.error.accountIdNotFound import io.novafoundation.nova.feature_staking_impl.domain.error.accountIdNotFound
import io.novafoundation.nova.runtime.ext.Geneses import io.novafoundation.nova.runtime.ext.Geneses
@@ -47,7 +46,14 @@ class RewardCalculatorFactory(
validatorsPrefs: AccountIdMap<ValidatorPrefs?>, validatorsPrefs: AccountIdMap<ValidatorPrefs?>,
scope: CoroutineScope scope: CoroutineScope
): RewardCalculator = withContext(Dispatchers.Default) { ): RewardCalculator = withContext(Dispatchers.Default) {
val totalIssuance = totalIssuanceRepository.getTotalIssuance(stakingOption.assetWithChain.chain.id) // For parachains (e.g. Asset Hub), staking lives on the parent relay chain.
// TotalIssuance must come from there, not from the parachain.
val stakingChainId = stakingOption.assetWithChain.chain.parentId ?: stakingOption.assetWithChain.chain.id
val totalIssuance = totalIssuanceRepository.getTotalIssuance(stakingChainId)
Log.d("PEZ_STAKING", "create(4-param) exposures=${exposures.size} validatorsPrefs=${validatorsPrefs.size}")
Log.d("PEZ_STAKING", "exposureKeys=${exposures.keys.take(3).map { it.take(16) }}")
Log.d("PEZ_STAKING", "prefKeys=${validatorsPrefs.keys.take(3).map { it.take(16) }}")
val validators = exposures.keys.mapNotNull { accountIdHex -> val validators = exposures.keys.mapNotNull { accountIdHex ->
val exposure = exposures[accountIdHex] ?: accountIdNotFound(accountIdHex) val exposure = exposures[accountIdHex] ?: accountIdNotFound(accountIdHex)
@@ -60,14 +66,27 @@ class RewardCalculatorFactory(
) )
} }
stakingOption.createRewardCalculator(validators, totalIssuance, scope) Log.d("PEZ_STAKING", "totalIssuance=$totalIssuance validators=${validators.size} stakingChainId=${stakingChainId.take(12)}")
stakingOption.createRewardCalculator(validators, totalIssuance, stakingChainId, scope)
} }
suspend fun create(stakingOption: StakingOption, scope: CoroutineScope): RewardCalculator = withContext(Dispatchers.Default) { suspend fun create(stakingOption: StakingOption, scope: CoroutineScope): RewardCalculator = withContext(Dispatchers.Default) {
val chainId = stakingOption.assetWithChain.chain.id val chain = stakingOption.assetWithChain.chain
val chainId = chain.id
// For parachains with a parent relay chain, staking exposures live on the relay chain
val exposureChainId = chain.parentId ?: chainId
val exposures = shareStakingSharedComputation.get().electedExposuresInActiveEra(chainId, scope) Log.d("PEZ_STAKING", "RewardCalculatorFactory.create() chainId=${chainId.take(12)} exposureChainId=${exposureChainId.take(12)} stakingType=${stakingOption.additional.stakingType}")
val validatorsPrefs = stakingRepository.getValidatorPrefs(chainId, exposures.keys)
val activeEra = stakingRepository.getActiveEraIndex(exposureChainId)
Log.d("PEZ_STAKING", "ActiveEra: $activeEra for ${exposureChainId.take(12)}")
val exposures = stakingRepository.getElectedValidatorsExposure(exposureChainId, activeEra)
Log.d("PEZ_STAKING", "Exposures: ${exposures.size}")
val validatorsPrefs = stakingRepository.getValidatorPrefs(exposureChainId, exposures.keys)
Log.d("PEZ_STAKING", "ValidatorPrefs: ${validatorsPrefs.size}")
create(stakingOption, exposures, validatorsPrefs, scope) create(stakingOption, exposures, validatorsPrefs, scope)
} }
@@ -75,6 +94,7 @@ class RewardCalculatorFactory(
private suspend fun StakingOption.createRewardCalculator( private suspend fun StakingOption.createRewardCalculator(
validators: List<RewardCalculationTarget>, validators: List<RewardCalculationTarget>,
totalIssuance: BigInteger, totalIssuance: BigInteger,
stakingChainId: ChainId,
scope: CoroutineScope scope: CoroutineScope
): RewardCalculator { ): RewardCalculator {
return when (unwrapNominationPools().stakingType) { return when (unwrapNominationPools().stakingType) {
@@ -82,8 +102,9 @@ class RewardCalculatorFactory(
val custom = customRelayChainCalculator(validators, totalIssuance, scope) val custom = customRelayChainCalculator(validators, totalIssuance, scope)
if (custom != null) return custom if (custom != null) return custom
val activePublicParachains = parasRepository.activePublicParachains(assetWithChain.chain.id) // Query parachains from the relay chain, not from Asset Hub
val inflationConfig = InflationConfig.create(chain.id, activePublicParachains) val activePublicParachains = parasRepository.activePublicParachains(stakingChainId)
val inflationConfig = InflationConfig.create(stakingChainId, activePublicParachains)
RewardCurveInflationRewardCalculator(validators, totalIssuance, inflationConfig) RewardCurveInflationRewardCalculator(validators, totalIssuance, inflationConfig)
} }
@@ -184,8 +184,8 @@ class RuntimeModule {
@Provides @Provides
@ApplicationScope @ApplicationScope
fun provideTotalIssuanceRepository( fun provideTotalIssuanceRepository(
@Named(LOCAL_STORAGE_SOURCE) localStorageSource: StorageDataSource, @Named(REMOTE_STORAGE_SOURCE) remoteStorageSource: StorageDataSource,
): TotalIssuanceRepository = RealTotalIssuanceRepository(localStorageSource) ): TotalIssuanceRepository = RealTotalIssuanceRepository(remoteStorageSource)
@Provides @Provides
@ApplicationScope @ApplicationScope