From 73b9a8391fa0b18308fa35f905e31cec77f5618f Mon Sep 17 00:00:00 2001
From: Ankan <10196091+Ank4n@users.noreply.github.com>
Date: Sun, 28 Apr 2024 14:35:51 +0200
Subject: [PATCH] [Staking] Runtime api if era rewards are pending to be
 claimed (#4301)

closes https://github.com/paritytech/polkadot-sdk/issues/426.
related to https://github.com/paritytech/polkadot-sdk/pull/1189.

Would help offchain programs to query if there are unclaimed pages of
rewards for a given era.

The logic could look like below

```js
// loop as long as all era pages are claimed.
while (api.call.stakingApi.pendingRewards(era, validator_stash)) {
  api.tx.staking.payout_stakers(validator_stash, era)
}
```
---
 polkadot/runtime/westend/src/lib.rs           |   4 +
 prdoc/pr_4301.prdoc                           |  13 +++
 substrate/bin/node/runtime/src/lib.rs         |   4 +
 .../frame/staking/runtime-api/src/lib.rs      |   5 +-
 substrate/frame/staking/src/lib.rs            |  28 ++++-
 substrate/frame/staking/src/pallet/impls.rs   |   4 +
 substrate/frame/staking/src/tests.rs          | 107 ++++++++++++++++++
 7 files changed, 163 insertions(+), 2 deletions(-)
 create mode 100644 prdoc/pr_4301.prdoc

diff --git a/polkadot/runtime/westend/src/lib.rs b/polkadot/runtime/westend/src/lib.rs
index 03ecd5c070b..de961bb4c39 100644
--- a/polkadot/runtime/westend/src/lib.rs
+++ b/polkadot/runtime/westend/src/lib.rs
@@ -2250,6 +2250,10 @@ sp_api::impl_runtime_apis! {
 		fn eras_stakers_page_count(era: sp_staking::EraIndex, account: AccountId) -> sp_staking::Page {
 			Staking::api_eras_stakers_page_count(era, account)
 		}
+
+		fn pending_rewards(era: sp_staking::EraIndex, account: AccountId) -> bool {
+			Staking::api_pending_rewards(era, account)
+		}
 	}
 
 	#[cfg(feature = "try-runtime")]
diff --git a/prdoc/pr_4301.prdoc b/prdoc/pr_4301.prdoc
new file mode 100644
index 00000000000..2ca2534243a
--- /dev/null
+++ b/prdoc/pr_4301.prdoc
@@ -0,0 +1,13 @@
+title: New runtime api to check if a validator has pending pages of rewards for an era.
+
+doc:
+  - audience:
+    - Node Dev
+    - Runtime User
+    description: |
+      Creates a new runtime api to check if reward for an era is pending for a validator. Era rewards are paged and this
+      api will return true as long as there is one or more pages of era reward which are not claimed.
+
+crates:
+- name: pallet-staking
+- name: pallet-staking-runtime-api
diff --git a/substrate/bin/node/runtime/src/lib.rs b/substrate/bin/node/runtime/src/lib.rs
index 0caaa8c7322..5d8016532a5 100644
--- a/substrate/bin/node/runtime/src/lib.rs
+++ b/substrate/bin/node/runtime/src/lib.rs
@@ -2791,6 +2791,10 @@ impl_runtime_apis! {
 		fn eras_stakers_page_count(era: sp_staking::EraIndex, account: AccountId) -> sp_staking::Page {
 			Staking::api_eras_stakers_page_count(era, account)
 		}
+
+		fn pending_rewards(era: sp_staking::EraIndex, account: AccountId) -> bool {
+			Staking::api_pending_rewards(era, account)
+		}
 	}
 
 	impl sp_consensus_babe::BabeApi<Block> for Runtime {
diff --git a/substrate/frame/staking/runtime-api/src/lib.rs b/substrate/frame/staking/runtime-api/src/lib.rs
index b04c383a077..7955f4184a4 100644
--- a/substrate/frame/staking/runtime-api/src/lib.rs
+++ b/substrate/frame/staking/runtime-api/src/lib.rs
@@ -30,7 +30,10 @@ sp_api::decl_runtime_apis! {
 		/// Returns the nominations quota for a nominator with a given balance.
 		fn nominations_quota(balance: Balance) -> u32;
 
-		/// Returns the page count of exposures for a validator in a given era.
+		/// Returns the page count of exposures for a validator `account` in a given era.
 		fn eras_stakers_page_count(era: sp_staking::EraIndex, account: AccountId) -> sp_staking::Page;
+
+		/// Returns true if validator `account` has pages to be claimed for the given era.
+		fn pending_rewards(era: sp_staking::EraIndex, account: AccountId) -> bool;
 	}
 }
diff --git a/substrate/frame/staking/src/lib.rs b/substrate/frame/staking/src/lib.rs
index 047ad6b87cc..692e62acfdf 100644
--- a/substrate/frame/staking/src/lib.rs
+++ b/substrate/frame/staking/src/lib.rs
@@ -1035,11 +1035,37 @@ where
 /// can and add more functions to it as needed.
 pub struct EraInfo<T>(sp_std::marker::PhantomData<T>);
 impl<T: Config> EraInfo<T> {
+	/// Returns true if validator has one or more page of era rewards not claimed yet.
+	// Also looks at legacy storage that can be cleaned up after #433.
+	pub fn pending_rewards(era: EraIndex, validator: &T::AccountId) -> bool {
+		let page_count = if let Some(overview) = <ErasStakersOverview<T>>::get(&era, validator) {
+			overview.page_count
+		} else {
+			if <ErasStakers<T>>::contains_key(era, validator) {
+				// this means non paged exposure, and we treat them as single paged.
+				1
+			} else {
+				// if no exposure, then no rewards to claim.
+				return false
+			}
+		};
+
+		// check if era is marked claimed in legacy storage.
+		if <Ledger<T>>::get(validator)
+			.map(|l| l.legacy_claimed_rewards.contains(&era))
+			.unwrap_or_default()
+		{
+			return false
+		}
+
+		ClaimedRewards::<T>::get(era, validator).len() < page_count as usize
+	}
+
 	/// Temporary function which looks at both (1) passed param `T::StakingLedger` for legacy
 	/// non-paged rewards, and (2) `T::ClaimedRewards` for paged rewards. This function can be
 	/// removed once `T::HistoryDepth` eras have passed and none of the older non-paged rewards
 	/// are relevant/claimable.
-	// Refer tracker issue for cleanup: #13034
+	// Refer tracker issue for cleanup: https://github.com/paritytech/polkadot-sdk/issues/433
 	pub(crate) fn is_rewards_claimed_with_legacy_fallback(
 		era: EraIndex,
 		ledger: &StakingLedger<T>,
diff --git a/substrate/frame/staking/src/pallet/impls.rs b/substrate/frame/staking/src/pallet/impls.rs
index f4d4a7133dd..4eb24311ab3 100644
--- a/substrate/frame/staking/src/pallet/impls.rs
+++ b/substrate/frame/staking/src/pallet/impls.rs
@@ -1183,6 +1183,10 @@ impl<T: Config> Pallet<T> {
 	pub fn api_eras_stakers_page_count(era: EraIndex, account: T::AccountId) -> Page {
 		EraInfo::<T>::get_page_count(era, &account)
 	}
+
+	pub fn api_pending_rewards(era: EraIndex, account: T::AccountId) -> bool {
+		EraInfo::<T>::pending_rewards(era, &account)
+	}
 }
 
 impl<T: Config> ElectionDataProvider for Pallet<T> {
diff --git a/substrate/frame/staking/src/tests.rs b/substrate/frame/staking/src/tests.rs
index 6cf5a56e5a6..d05752f54be 100644
--- a/substrate/frame/staking/src/tests.rs
+++ b/substrate/frame/staking/src/tests.rs
@@ -6796,6 +6796,113 @@ fn test_validator_exposure_is_backward_compatible_with_non_paged_rewards_payout(
 	});
 }
 
+#[test]
+fn test_runtime_api_pending_rewards() {
+	ExtBuilder::default().build_and_execute(|| {
+		// GIVEN
+		let err_weight = <Test as Config>::WeightInfo::payout_stakers_alive_staked(0);
+		let stake = 100;
+
+		// validator with non-paged exposure, rewards marked in legacy claimed rewards.
+		let validator_one = 301;
+		// validator with non-paged exposure, rewards marked in paged claimed rewards.
+		let validator_two = 302;
+		// validator with paged exposure.
+		let validator_three = 303;
+
+		// Set staker
+		for v in validator_one..=validator_three {
+			let _ = Balances::make_free_balance_be(&v, stake);
+			assert_ok!(Staking::bond(RuntimeOrigin::signed(v), stake, RewardDestination::Staked));
+		}
+
+		// Add reward points
+		let reward = EraRewardPoints::<AccountId> {
+			total: 1,
+			individual: vec![(validator_one, 1), (validator_two, 1), (validator_three, 1)]
+				.into_iter()
+				.collect(),
+		};
+		ErasRewardPoints::<Test>::insert(0, reward);
+
+		// build exposure
+		let mut individual_exposures: Vec<IndividualExposure<AccountId, Balance>> = vec![];
+		for i in 0..=MaxExposurePageSize::get() {
+			individual_exposures.push(IndividualExposure { who: i.into(), value: stake });
+		}
+		let exposure = Exposure::<AccountId, Balance> {
+			total: stake * (MaxExposurePageSize::get() as Balance + 2),
+			own: stake,
+			others: individual_exposures,
+		};
+
+		// add non-paged exposure for one and two.
+		<ErasStakers<Test>>::insert(0, validator_one, exposure.clone());
+		<ErasStakers<Test>>::insert(0, validator_two, exposure.clone());
+		// add paged exposure for third validator
+		EraInfo::<Test>::set_exposure(0, &validator_three, exposure);
+
+		// add some reward to be distributed
+		ErasValidatorReward::<Test>::insert(0, 1000);
+
+		// mark rewards claimed for validator_one in legacy claimed rewards
+		<Ledger<Test>>::insert(
+			validator_one,
+			StakingLedgerInspect {
+				stash: validator_one,
+				total: stake,
+				active: stake,
+				unlocking: Default::default(),
+				legacy_claimed_rewards: bounded_vec![0],
+			},
+		);
+
+		// SCENARIO ONE: rewards already marked claimed in legacy storage.
+		// runtime api should return false for pending rewards for validator_one.
+		assert!(!EraInfo::<Test>::pending_rewards(0, &validator_one));
+		// and if we try to pay, we get an error.
+		assert_noop!(
+			Staking::payout_stakers(RuntimeOrigin::signed(1337), validator_one, 0),
+			Error::<Test>::AlreadyClaimed.with_weight(err_weight)
+		);
+
+		// SCENARIO TWO: non-paged exposure
+		// validator two has not claimed rewards, so pending rewards is true.
+		assert!(EraInfo::<Test>::pending_rewards(0, &validator_two));
+		// and payout works
+		assert_ok!(Staking::payout_stakers(RuntimeOrigin::signed(1337), validator_two, 0));
+		// now pending rewards is false.
+		assert!(!EraInfo::<Test>::pending_rewards(0, &validator_two));
+		// and payout fails
+		assert_noop!(
+			Staking::payout_stakers(RuntimeOrigin::signed(1337), validator_two, 0),
+			Error::<Test>::AlreadyClaimed.with_weight(err_weight)
+		);
+
+		// SCENARIO THREE: validator with paged exposure (two pages).
+		// validator three has not claimed rewards, so pending rewards is true.
+		assert!(EraInfo::<Test>::pending_rewards(0, &validator_three));
+		// and payout works
+		assert_ok!(Staking::payout_stakers(RuntimeOrigin::signed(1337), validator_three, 0));
+		// validator three has two pages of exposure, so pending rewards is still true.
+		assert!(EraInfo::<Test>::pending_rewards(0, &validator_three));
+		// payout again
+		assert_ok!(Staking::payout_stakers(RuntimeOrigin::signed(1337), validator_three, 0));
+		// now pending rewards is false.
+		assert!(!EraInfo::<Test>::pending_rewards(0, &validator_three));
+		// and payout fails
+		assert_noop!(
+			Staking::payout_stakers(RuntimeOrigin::signed(1337), validator_three, 0),
+			Error::<Test>::AlreadyClaimed.with_weight(err_weight)
+		);
+
+		// for eras with no exposure, pending rewards is false.
+		assert!(!EraInfo::<Test>::pending_rewards(0, &validator_one));
+		assert!(!EraInfo::<Test>::pending_rewards(0, &validator_two));
+		assert!(!EraInfo::<Test>::pending_rewards(0, &validator_three));
+	});
+}
+
 mod staking_interface {
 	use frame_support::storage::with_storage_layer;
 	use sp_staking::StakingInterface;
-- 
GitLab