1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
// Copyright 2021 Parity Technologies (UK) Ltd.
// This file is part of Polkadot.

// Polkadot is free software: you can redistribute it and/or modify
// it under the terms of the GNU General Public License as published by
// the Free Software Foundation, either version 3 of the License, or
// (at your option) any later version.

// Polkadot is distributed in the hope that it will be useful,
// but WITHOUT ANY WARRANTY; without even the implied warranty of
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
// GNU General Public License for more details.

// You should have received a copy of the GNU General Public License
// along with Polkadot.  If not, see <http://www.gnu.org/licenses/>.

use std::collections::{BTreeSet, HashMap};

use polkadot_primitives::v2::{CandidateHash, SessionIndex, ValidatorIndex};

use crate::LOG_TARGET;

/// Type used for counting potential spam votes.
type SpamCount = u32;

/// How many unconfirmed disputes a validator is allowed to import (per session).
///
/// Unconfirmed means: Node has not seen the candidate be included on any chain, it has not cast a
/// vote itself on that dispute, the dispute has not yet reached more than a third of
/// validator's votes and the including relay chain block has not yet been finalized.
///
/// Exact number of `MAX_SPAM_VOTES` is not that important here. It is important that the number is
/// low enough to not cause resource exhaustion (disk & memory) on the importing validator, even if
/// multiple validators fully make use of their assigned spam slots.
///
/// Also if things are working properly, this number cannot really be too low either, as all
/// relevant disputes _should_ have been seen as included by enough validators. (Otherwise the
/// candidate would not have been available in the first place and could not have been included.)
/// So this is really just a fallback mechanism if things go terribly wrong.
#[cfg(not(test))]
const MAX_SPAM_VOTES: SpamCount = 50;
#[cfg(test)]
const MAX_SPAM_VOTES: SpamCount = 1;

/// Spam slots for raised disputes concerning unknown candidates.
pub struct SpamSlots {
	/// Counts per validator and session.
	///
	/// Must not exceed `MAX_SPAM_VOTES`.
	slots: HashMap<(SessionIndex, ValidatorIndex), SpamCount>,

	/// All unconfirmed candidates we are aware of right now.
	unconfirmed: UnconfirmedDisputes,
}

/// Unconfirmed disputes to be passed at initialization.
pub type UnconfirmedDisputes = HashMap<(SessionIndex, CandidateHash), BTreeSet<ValidatorIndex>>;

impl SpamSlots {
	/// Recover `SpamSlots` from state on startup.
	///
	/// Initialize based on already existing active disputes.
	pub fn recover_from_state(unconfirmed_disputes: UnconfirmedDisputes) -> Self {
		let mut slots: HashMap<(SessionIndex, ValidatorIndex), SpamCount> = HashMap::new();
		for ((session, _), validators) in unconfirmed_disputes.iter() {
			for validator in validators {
				let spam_vote_count = slots.entry((*session, *validator)).or_default();
				*spam_vote_count += 1;
				if *spam_vote_count > MAX_SPAM_VOTES {
					gum::debug!(
						target: LOG_TARGET,
						?session,
						?validator,
						count = ?spam_vote_count,
						"Import exceeded spam slot for validator"
					);
				}
			}
		}

		Self { slots, unconfirmed: unconfirmed_disputes }
	}

	/// Increase a "voting invalid" validator's spam slot.
	///
	/// This function should get called for any validator's invalidity vote for any not yet
	/// confirmed dispute.
	///
	/// Returns: `true` if validator still had vacant spam slots, `false` otherwise.
	pub fn add_unconfirmed(
		&mut self,
		session: SessionIndex,
		candidate: CandidateHash,
		validator: ValidatorIndex,
	) -> bool {
		let spam_vote_count = self.slots.entry((session, validator)).or_default();
		if *spam_vote_count >= MAX_SPAM_VOTES {
			return false
		}
		let validators = self.unconfirmed.entry((session, candidate)).or_default();

		if validators.insert(validator) {
			// We only increment spam slots once per candidate, as each validator has to provide an
			// opposing vote for sending out its own vote. Therefore, receiving multiple votes for
			// a single candidate is expected and should not get punished here.
			*spam_vote_count += 1;
		}

		true
	}

	/// Clear out spam slots for a given candidate in a session.
	///
	/// This effectively reduces the spam slot count for all validators participating in a dispute
	/// for that candidate. You should call this function once a dispute became obsolete or got
	/// confirmed and thus votes for it should no longer be treated as potential spam.
	pub fn clear(&mut self, key: &(SessionIndex, CandidateHash)) {
		if let Some(validators) = self.unconfirmed.remove(key) {
			let (session, _) = key;
			for validator in validators {
				if let Some(spam_vote_count) = self.slots.remove(&(*session, validator)) {
					let new = spam_vote_count - 1;
					if new > 0 {
						self.slots.insert((*session, validator), new);
					}
				}
			}
		}
	}
	/// Prune all spam slots for sessions older than the given index.
	pub fn prune_old(&mut self, oldest_index: SessionIndex) {
		self.unconfirmed.retain(|(session, _), _| *session >= oldest_index);
		self.slots.retain(|(session, _), _| *session >= oldest_index);
	}
}