1use super::ModerationCharterRewardSplit;
4use crate::balances::credits::Credits;
5use crate::ProtocolError;
6use platform_value::Identifier;
7use std::collections::{BTreeMap, BTreeSet};
8
9const WHOLE: u128 = 100;
11
12impl ModerationCharterRewardSplit {
13 pub fn payouts(
33 &self,
34 pot: Credits,
35 leader_id: Identifier,
36 members: &BTreeSet<Identifier>,
37 action_counts: &BTreeMap<Identifier, u32>,
38 ) -> Result<BTreeMap<Identifier, Credits>, ProtocolError> {
39 let percentages = [self.leader, self.equal, self.actions];
40 if percentages
41 .iter()
42 .map(|share| u128::from(*share))
43 .sum::<u128>()
44 != WHOLE
45 {
46 return Err(ProtocolError::CorruptedCodeExecution(format!(
47 "a reward split of {}/{}/{} does not add up to 100",
48 self.leader, self.equal, self.actions
49 )));
50 }
51 let pot = u128::from(pot);
52 let percent_of_pot = |share: u8| pot * u128::from(share) / WHOLE;
53
54 let mut payouts: BTreeMap<Identifier, u128> = BTreeMap::new();
55 let mut pay = |identity_id: Identifier, amount: u128| {
56 if amount > 0 {
57 *payouts.entry(identity_id).or_default() += amount;
58 }
59 };
60
61 pay(leader_id, percent_of_pot(self.leader));
62
63 let equal_share = percent_of_pot(self.equal);
64 let others: Vec<Identifier> = members
65 .iter()
66 .filter(|member| **member != leader_id)
67 .copied()
68 .collect();
69 if others.is_empty() {
70 pay(leader_id, equal_share);
71 } else {
72 let each = equal_share / others.len() as u128;
73 for member in &others {
74 pay(*member, each);
75 }
76 }
77
78 let action_share = percent_of_pot(self.actions);
79 let team: Vec<Identifier> = std::iter::once(leader_id).chain(others).collect();
80 let counted: Vec<(Identifier, u128)> = team
81 .iter()
82 .map(|identity_id| {
83 (
84 *identity_id,
85 u128::from(action_counts.get(identity_id).copied().unwrap_or_default()),
86 )
87 })
88 .collect();
89 let total_actions: u128 = counted.iter().map(|(_, count)| count).sum();
90 let by_count: Option<Vec<(Identifier, u128)>> = counted
92 .iter()
93 .map(|(identity_id, count)| {
94 (action_share * count)
95 .checked_div(total_actions)
96 .map(|amount| (*identity_id, amount))
97 })
98 .collect();
99 match by_count {
100 Some(parts) => {
101 for (identity_id, amount) in parts {
102 pay(identity_id, amount);
103 }
104 }
105 None => {
106 let each = action_share / team.len() as u128;
107 for identity_id in &team {
108 pay(*identity_id, each);
109 }
110 }
111 }
112
113 payouts
114 .into_iter()
115 .map(|(identity_id, amount)| {
116 Credits::try_from(amount)
118 .map(|amount| (identity_id, amount))
119 .map_err(|_| {
120 ProtocolError::CorruptedCodeExecution(
121 "a payout of a moderators pot exceeds the pot".to_string(),
122 )
123 })
124 })
125 .collect()
126 }
127}
128
129#[cfg(test)]
130mod tests {
131 use super::*;
132
133 fn id(seed: u8) -> Identifier {
134 Identifier::from([seed; 32])
135 }
136
137 fn split(leader: u8, equal: u8, actions: u8) -> ModerationCharterRewardSplit {
138 ModerationCharterRewardSplit {
139 leader,
140 equal,
141 actions,
142 }
143 }
144
145 fn members(seeds: &[u8]) -> BTreeSet<Identifier> {
146 seeds.iter().map(|seed| id(*seed)).collect()
147 }
148
149 fn counts(entries: &[(u8, u32)]) -> BTreeMap<Identifier, u32> {
150 entries
151 .iter()
152 .map(|(seed, count)| (id(*seed), *count))
153 .collect()
154 }
155
156 #[test]
157 fn should_pay_the_leader_share_the_equal_share_and_the_action_share_by_count() {
158 let payouts = split(10, 40, 50)
161 .payouts(
162 1_000,
163 id(1),
164 &members(&[2, 3]),
165 &counts(&[(1, 1), (2, 3), (3, 1)]),
166 )
167 .expect("expected a payout");
168 assert_eq!(
169 payouts,
170 BTreeMap::from([(id(1), 100 + 100), (id(2), 200 + 300), (id(3), 200 + 100)])
171 );
172 assert_eq!(payouts.values().sum::<Credits>(), 1_000);
173 }
174
175 #[test]
176 fn should_round_every_part_down_and_leave_the_remainder_in_the_pot() {
177 let payouts = split(10, 40, 50)
181 .payouts(
182 1_003,
183 id(1),
184 &members(&[2, 3, 4]),
185 &counts(&[(2, 2), (3, 1)]),
186 )
187 .expect("expected a payout");
188 assert_eq!(
189 payouts,
190 BTreeMap::from([
191 (id(1), 100),
192 (id(2), 133 + 334),
193 (id(3), 133 + 167),
194 (id(4), 133),
195 ])
196 );
197 assert_eq!(1_003 - payouts.values().sum::<Credits>(), 3);
198 }
199
200 #[test]
201 fn should_split_the_action_share_equally_when_nobody_acted() {
202 let payouts = split(0, 40, 60)
205 .payouts(1_000, id(1), &members(&[2, 3]), &BTreeMap::new())
206 .expect("expected a payout");
207 assert_eq!(
208 payouts,
209 BTreeMap::from([(id(1), 200), (id(2), 200 + 200), (id(3), 200 + 200)])
210 );
211 }
212
213 #[test]
214 fn should_pay_a_leader_alone_the_whole_pot() {
215 let payouts = split(10, 40, 50)
216 .payouts(1_000, id(1), &BTreeSet::new(), &counts(&[(1, 7)]))
217 .expect("expected a payout");
218 assert_eq!(payouts, BTreeMap::from([(id(1), 1_000)]));
219 }
220
221 #[test]
222 fn should_ignore_the_count_of_an_identity_that_is_not_on_the_team() {
223 let payouts = split(0, 0, 100)
224 .payouts(900, id(1), &members(&[2]), &counts(&[(2, 1), (9, 5)]))
225 .expect("expected a payout");
226 assert_eq!(payouts, BTreeMap::from([(id(2), 900)]));
227 }
228
229 #[test]
230 fn should_leave_out_an_identity_whose_parts_round_to_nothing() {
231 let payouts = split(0, 0, 100)
233 .payouts(
234 2,
235 id(1),
236 &members(&[2, 3]),
237 &counts(&[(1, 1), (2, 1), (3, 1)]),
238 )
239 .expect("expected no failure");
240 assert!(payouts.is_empty());
241 let payouts = split(0, 0, 100)
243 .payouts(10, id(1), &members(&[2]), &counts(&[(1, 1)]))
244 .expect("expected a payout");
245 assert_eq!(payouts, BTreeMap::from([(id(1), 10)]));
246 }
247
248 #[test]
249 fn should_never_leave_the_leader_among_the_members() {
250 let payouts = split(0, 100, 0)
252 .payouts(100, id(1), &members(&[1, 2]), &BTreeMap::new())
253 .expect("expected a payout");
254 assert_eq!(payouts, BTreeMap::from([(id(2), 100)]));
255 }
256
257 #[test]
258 fn should_pay_the_largest_pot_without_overflowing() {
259 let pot = Credits::MAX;
260 let payouts = split(33, 33, 34)
261 .payouts(
262 pot,
263 id(1),
264 &members(&[2]),
265 &counts(&[(1, u32::MAX), (2, u32::MAX)]),
266 )
267 .expect("expected a payout");
268 let paid = payouts
269 .values()
270 .try_fold(0 as Credits, |sum, amount| sum.checked_add(*amount));
271 assert!(paid.is_some_and(|paid| paid <= pot));
272 }
273
274 #[test]
275 fn should_refuse_a_split_that_does_not_add_up_to_one_hundred() {
276 assert!(split(10, 40, 40)
277 .payouts(1_000, id(1), &members(&[2]), &BTreeMap::new())
278 .is_err());
279 assert!(split(100, 100, 0)
280 .payouts(1_000, id(1), &members(&[2]), &BTreeMap::new())
281 .is_err());
282 }
283}