Repository: paritytech/polkadot-sdk
Pull request #8601: [stable2412] Backport #8585
Author: login: paritytech-release-backport-bot[bot]
Labels: name: A3-backport
description: Pull request is already reviewed well in another branch.
Created: 2025-05-21T16:05:23Z | Merged: 2025-05-21T17:39:04Z

Backport #8585 into `stable2412` from kianenigma.

See the [documentation](https://github.com/paritytech/polkadot-sdk/blob/master/docs/BACKPORT.md) on how to use this bot.

<!--
  # To be used by other automation, do not modify:
  original-pr-number: #${pull_number}
-->


Diff:
diff --git a/prdoc/pr_8585.prdoc b/prdoc/pr_8585.prdoc
new file mode 100644
index 0000000000000..4eb7f25b0a935
--- /dev/null
+++ b/prdoc/pr_8585.prdoc
@@ -0,0 +1,14 @@
+title: fix epmb solution duplicate issue + add remote mining apparatus to epm
+doc:
+- audience: Runtime Dev
+  description: |
+    Prevents the NPoS election process from accepting duplicate voters and targets.
+crates:
+- name: pallet-election-provider-multi-phase
+  bump: minor
+- name: frame-election-provider-solution-type
+  bump: patch
+- name: frame-election-provider-support
+  bump: patch
+- name: sp-npos-elections
+  bump: minor
diff --git a/substrate/frame/election-provider-multi-phase/Cargo.toml b/substrate/frame/election-provider-multi-phase/Cargo.toml
index 0b289e640c843..a3a8b84d338a3 100644
--- a/substrate/frame/election-provider-multi-phase/Cargo.toml
+++ b/substrate/frame/election-provider-multi-phase/Cargo.toml
@@ -45,6 +45,7 @@ sp-tracing = { default-features = true, path = "../../primitives/tracing" }
 pallet-balances = { default-features = true, path = "../balances" }
 frame-benchmarking = { default-features = true, path = "../benchmarking" }
 
+
 [features]
 default = ["std"]
 std = [
diff --git a/substrate/frame/election-provider-multi-phase/src/lib.rs b/substrate/frame/election-provider-multi-phase/src/lib.rs
index 06cb2963d762d..8cffc82f1abd6 100644
--- a/substrate/frame/election-provider-multi-phase/src/lib.rs
+++ b/substrate/frame/election-provider-multi-phase/src/lib.rs
@@ -458,9 +458,9 @@ where
 /// These are stored together because they are often accessed together.
 #[derive(PartialEq, Eq, Clone, Encode, Decode, RuntimeDebug, Default, TypeInfo)]
 #[scale_info(skip_type_params(T))]
-pub struct RoundSnapshot<AccountId, DataProvider> {
+pub struct RoundSnapshot<AccountId, VoterType> {
 	/// All of the voters.
-	pub voters: Vec<DataProvider>,
+	pub voters: Vec<VoterType>,
 	/// All of the targets.
 	pub targets: Vec<AccountId>,
 }
diff --git a/substrate/frame/election-provider-multi-phase/src/unsigned.rs b/substrate/frame/election-provider-multi-phase/src/unsigned.rs
index 191131ed3acc3..451f22b502849 100644
--- a/substrate/frame/election-provider-multi-phase/src/unsigned.rs
+++ b/substrate/frame/election-provider-multi-phase/src/unsigned.rs
@@ -551,6 +551,13 @@ impl<T: MinerConfig> Miner<T> {
 
 		let is_trimmed = TrimmingStatus { weight: weight_trimmed, length: length_trimmed };
 
+		log_no_system!(
+			debug,
+			"feasible solution mined: trimmed? {:?}, score: {:?}, encoded size: {:?}",
+			is_trimmed,
+			score,
+			solution.encoded_size()
+		);
 		Ok((solution, score, size, is_trimmed))
 	}
 
diff --git a/substrate/frame/election-provider-support/solution-type/src/single_page.rs b/substrate/frame/election-provider-support/solution-type/src/single_page.rs
index de59df162c8ad..72fb5b288c0d1 100644
--- a/substrate/frame/election-provider-support/solution-type/src/single_page.rs
+++ b/substrate/frame/election-provider-support/solution-type/src/single_page.rs
@@ -148,9 +148,9 @@ pub(crate) fn generate(def: crate::SolutionDef) -> Result<TokenStream2> {
 				voter_at: impl Fn(Self::VoterIndex) -> Option<A>,
 				target_at: impl Fn(Self::TargetIndex) -> Option<A>,
 			) -> Result<_fepsp::Vec<_feps::Assignment<A, #weight_type>>, _feps::Error> {
-				let mut #assignment_name: _fepsp::Vec<_feps::Assignment<A, #weight_type>> = Default::default();
+				let mut #assignment_name: _fepsp::BTreeMap<Self::VoterIndex, _feps::Assignment<A, #weight_type>> = Default::default();
 				#into_impl
-				Ok(#assignment_name)
+				Ok(#assignment_name.into_values().collect())
 			}
 
 			fn voter_count(&self) -> usize {
@@ -337,13 +337,18 @@ pub(crate) fn into_impl(
 	let into_impl_single = {
 		let name = vote_field(1);
 		quote!(
-			for (voter_index, target_index) in self.#name {
-				#assignments.push(_feps::Assignment {
-					who: voter_at(voter_index).or_invalid_index()?,
-					distribution: vec![
-						(target_at(target_index).or_invalid_index()?, #per_thing::one())
-					],
-				})
+			for (voter_index, target_index) in self.#name {;
+				if #assignments.contains_key(&voter_index) {
+					return Err(_feps::Error::DuplicateVoter);
+				} else {
+					#assignments.insert(
+						voter_index,
+						_feps::Assignment {
+							who: voter_at(voter_index).or_invalid_index()?,
+							distribution: vec![(target_at(target_index).or_invalid_index()?, #per_thing::one())],
+						}
+					);
+				}
 			}
 		)
 	};
@@ -353,10 +358,21 @@ pub(crate) fn into_impl(
 			let name = vote_field(c);
 			quote!(
 				for (voter_index, inners, t_last_idx) in self.#name {
+					if #assignments.contains_key(&voter_index) {
+						return Err(_feps::Error::DuplicateVoter);
+					}
+
+					let mut targets_seen = _fepsp::BTreeSet::new();
+
 					let mut sum = #per_thing::zero();
 					let mut inners_parsed = inners
 						.iter()
 						.map(|(ref t_idx, p)| {
+							if targets_seen.contains(t_idx) {
+								return Err(_feps::Error::DuplicateTarget);
+							} else {
+								targets_seen.insert(t_idx);
+							}
 							sum = _fepsp::sp_arithmetic::traits::Saturating::saturating_add(sum, *p);
 							let target = target_at(*t_idx).or_invalid_index()?;
 							Ok((target, *p))
@@ -367,6 +383,13 @@ pub(crate) fn into_impl(
 						return Err(_feps::Error::SolutionWeightOverflow);
 					}
 
+					// check that the last target index is also unique.
+					if targets_seen.contains(&t_last_idx) {
+						return Err(_feps::Error::DuplicateTarget);
+					} else {
+						// no need to insert, we are done.
+					}
+
 					// defensive only. Since Percent doesn't have `Sub`.
 					let p_last = _fepsp::sp_arithmetic::traits::Saturating::saturating_sub(
 						#per_thing::one(),
@@ -375,10 +398,13 @@ pub(crate) fn into_impl(
 
 					inners_parsed.push((target_at(t_last_idx).or_invalid_index()?, p_last));
 
-					#assignments.push(_feps::Assignment {
-						who: voter_at(voter_index).or_invalid_index()?,
-						distribution: inners_parsed,
-					});
+					#assignments.insert(
+						voter_index,
+						_feps::Assignment {
+							who: voter_at(voter_index).or_invalid_index()?,
+							distribution: inners_parsed,
+						}
+					);
 				}
 			)
 		})
diff --git a/substrate/frame/election-provider-support/src/lib.rs b/substrate/frame/election-provider-support/src/lib.rs
index cb3249e388a31..854f66e6a42f9 100644
--- a/substrate/frame/election-provider-support/src/lib.rs
+++ b/substrate/frame/election-provider-support/src/lib.rs
@@ -205,7 +205,10 @@ use sp_runtime::TryRuntimeError;
 // re-export for the solution macro, with the dependencies of the macro.
 #[doc(hidden)]
 pub mod private {
-	pub use alloc::{collections::btree_set::BTreeSet, vec::Vec};
+	pub use alloc::{
+		collections::{btree_map::BTreeMap, btree_set::BTreeSet},
+		vec::Vec,
+	};
 	pub use codec;
 	pub use scale_info;
 	pub use sp_arithmetic;
diff --git a/substrate/frame/election-provider-support/src/tests.rs b/substrate/frame/election-provider-support/src/tests.rs
index 6e3deb9e38346..849017ee2a1d7 100644
--- a/substrate/frame/election-provider-support/src/tests.rs
+++ b/substrate/frame/election-provider-support/src/tests.rs
@@ -244,6 +244,65 @@ mod solution_type {
 		);
 	}
 
+	#[test]
+	fn prevents_target_duplicate_into_assignment() {
+		let voter_at = |a: u32| -> Option<AccountId> { Some(a as AccountId) };
+		let target_at = |a: u16| -> Option<AccountId> { Some(a as AccountId) };
+
+		// case 1: duplicate target in votes2.
+		let solution = TestSolution { votes2: vec![(0, [(1, p(50))], 1)], ..Default::default() };
+		assert_eq!(
+			solution.into_assignment(&voter_at, &target_at).unwrap_err(),
+			NposError::DuplicateTarget,
+		);
+
+		// case 2: duplicate target in votes3.
+		let solution =
+			TestSolution { votes3: vec![(0, [(1, p(25)), (2, p(50))], 1)], ..Default::default() };
+		assert_eq!(
+			solution.into_assignment(&voter_at, &target_at).unwrap_err(),
+			NposError::DuplicateTarget,
+		);
+	}
+
+	#[test]
+	fn prevents_voter_duplicate_into_assignment() {
+		let voter_at = |a: u32| -> Option<AccountId> { Some(a as AccountId) };
+		let target_at = |a: u16| -> Option<AccountId> { Some(a as AccountId) };
+
+		// case 1: there is a duplicate among two different fields
+		let solution = TestSolution {
+			// voter index 0 is present here
+			votes1: vec![(0, 0), (1, 0)],
+			// voter index 0 is also present here
+			votes2: vec![(0, [(1, p(50))], 2)],
+			..Default::default()
+		};
+
+		assert_eq!(
+			solution.into_assignment(&voter_at, &target_at).unwrap_err(),
+			NposError::DuplicateVoter,
+		);
+
+		// case 2: there is a duplicate in the same field
+		let solution = TestSolution { votes1: vec![(0, 0), (0, 1)], ..Default::default() };
+		assert_eq!(
+			solution.into_assignment(&voter_at, &target_at).unwrap_err(),
+			NposError::DuplicateVoter,
+		);
+
+		// case 2.1: there is a duplicate in the same fieild, a bit more complex
+		let solution = TestSolution {
+			votes1: vec![(0, 0)],
+			votes2: vec![(1, [(1, p(50))], 2), (1, [(3, p(50))], 4)],
+			..Default::default()
+		};
+		assert_eq!(
+			solution.into_assignment(&voter_at, &target_at).unwrap_err(),
+			NposError::DuplicateVoter,
+		);
+	}
+
 	#[test]
 	fn from_and_into_assignment_works() {
 		let voters = vec![2 as AccountId, 4, 1, 5, 3];
diff --git a/substrate/primitives/npos-elections/src/lib.rs b/substrate/primitives/npos-elections/src/lib.rs
index 82ac40fe27378..e6c8a9e0efdf3 100644
--- a/substrate/primitives/npos-elections/src/lib.rs
+++ b/substrate/primitives/npos-elections/src/lib.rs
@@ -127,6 +127,10 @@ pub enum Error {
 	InvalidSupportEdge,
 	/// The number of voters is bigger than the `MaxVoters` bound.
 	TooManyVoters,
+	/// A duplicate voter was detected.
+	DuplicateVoter,
+	/// A duplicate target was detected.
+	DuplicateTarget,
 }
 
 /// A type which is used in the API of this crate as a numeric weight of a vote, most often the


Reviews:
user: login: EgorPopelyaev
state: APPROVED
author_association: CONTRIBUTOR
submitted_at: 2025-05-21T16:56:54Z
user: login: ggwpez
state: COMMENTED
author_association: MEMBER
submitted_at: 2025-05-21T16:58:53Z
user: login: kianenigma
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-05-21T16:59:41Z
user: login: kianenigma
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-05-21T17:03:50Z
user: login: ggwpez
body: (preemptively approving)
state: APPROVED
author_association: MEMBER
submitted_at: 2025-05-21T17:04:32Z
user: login: seadanda
state: APPROVED
author_association: CONTRIBUTOR
submitted_at: 2025-05-21T17:16:17Z

Review comments:
diff_hunk: @@ -0,0 +1,15 @@
+title: fix epmb solution duplicate issue + add remote mining apparatus to epm
+doc:
+- audience: Runtime Dev
+  description: |
+    Prevents the NPoS election process from accepting duplicate voters and targets.
+crates:
+- name: pallet-election-provider-multi-phase
+  bump: minor
+- name: frame-election-provider-solution-type
+  bump: patch
+- name: frame-election-provider-support
+  bump: patch
+- name: sp-npos-elections
+  bump: major
path: prdoc/pr_8585.prdoc
user: login: ggwpez
body: Can we not re-use another error?  
Either way it should be `minor`, otherwise we will bump crate versions.
created_at: 2025-05-21T16:58:53Z
author_association: MEMBER
diff_hunk: @@ -0,0 +1,15 @@
+title: fix epmb solution duplicate issue + add remote mining apparatus to epm
+doc:
+- audience: Runtime Dev
+  description: |
+    Prevents the NPoS election process from accepting duplicate voters and targets.
+crates:
+- name: pallet-election-provider-multi-phase
+  bump: minor
+- name: frame-election-provider-solution-type
+  bump: patch
+- name: frame-election-provider-support
+  bump: patch
+- name: sp-npos-elections
+  bump: major
path: prdoc/pr_8585.prdoc
user: login: kianenigma
body: Actually we can, lemme fix it in both
created_at: 2025-05-21T16:59:41Z
author_association: CONTRIBUTOR
diff_hunk: @@ -0,0 +1,15 @@
+title: fix epmb solution duplicate issue + add remote mining apparatus to epm
+doc:
+- audience: Runtime Dev
+  description: |
+    Prevents the NPoS election process from accepting duplicate voters and targets.
+crates:
+- name: pallet-election-provider-multi-phase
+  bump: minor
+- name: frame-election-provider-solution-type
+  bump: patch
+- name: frame-election-provider-support
+  bump: patch
+- name: sp-npos-elections
+  bump: major
path: prdoc/pr_8585.prdoc
user: login: kianenigma
body: Nope sorry all other errors are very unrelated. Let's just do minor
created_at: 2025-05-21T17:03:50Z
author_association: CONTRIBUTOR

PR comments:
user: login: paritytech-release-backport-bot[bot]
created_at: 2025-05-21T16:05:25Z
body: Please cherry-pick the changes locally and resolve any conflicts.
```bash
git fetch origin backport-8585-to-stable2412
git worktree add --checkout .worktree/backport-8585-to-stable2412 backport-8585-to-stable2412
cd .worktree/backport-8585-to-stable2412
git reset --hard HEAD^
git cherry-pick -x 5d71d474ab78dacc37f89698a71baa45b41d0302
git push --force-with-lease
```
author_association: NONE
user: login: github-actions[bot]
created_at: 2025-05-21T16:09:04Z
body:   This pull request is amending an existing release. Please proceed with extreme caution,
  as to not impact downstream teams that rely on the stability of it. Some things to consider:
  - Backports are only for 'patch' or 'minor' changes. No 'major' or other breaking change.
  - Should be a legit *fix* for some  bug, not adding tons of new features.
  - Must either be already audited or not need an audit.

  <details><summary><i>Emergency Bypass</i></summary>
  <p>

  If you really need to bypass this check: add <code>validate: false</code> to each crate
  in the Prdoc where a breaking change is introduced. This will release a new major
  version of that crate and all its reverse dependencies and basically break the release.

  </p>
  </details> 

author_association: CONTRIBUTOR