Skip to main content

dpp/moderation_charter/
reward_split.rs

1//! How a seated team's moderators pot is paid out: its proposal's `rewardSplit`.
2
3use super::ModerationCharterRewardSplit;
4use crate::balances::credits::Credits;
5use crate::ProtocolError;
6use platform_value::Identifier;
7use std::collections::{BTreeMap, BTreeSet};
8
9/// The percentages of a reward split are of a whole of 100.
10const WHOLE: u128 = 100;
11
12impl ModerationCharterRewardSplit {
13    /// What a settle of `pot` credits pays each identity of a seated team: the leader
14    /// `leader_id`, and `members`, the active members besides it. `action_counts` holds the
15    /// moderation actions each one signed since the pot was last settled; a count of an
16    /// identity that is not on the team is left out.
17    ///
18    /// - The leader share, `leader` percent of the pot, goes to the leader.
19    /// - The equal share, `equal` percent, is split equally between the members; with no
20    ///   member besides the leader it goes to the leader, who is then the whole team.
21    /// - The action share, `actions` percent, is split between the team, the leader included,
22    ///   in proportion to each one's action count; when nobody acted, it is split equally
23    ///   between them.
24    ///
25    /// Every share and every part of one is rounded down to the credit, so a settle never pays
26    /// more than the pot holds. What the rounding leaves, a few credits at most, stays in the
27    /// pot for the next settle, so no identity is favoured by the order of the identity ids.
28    /// An identity whose parts all round down to nothing is left out of the result.
29    ///
30    /// Fails when the three percentages do not add up to 100, which the charter contract's
31    /// `propertyConstraints` rule `rewardSplitIsWhole` refuses at every write.
32    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        // In proportion to the counts; with no count at all, equally.
91        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                // Every part is a share of the pot, and the parts add up to at most the pot.
117                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        // 10/40/50 of 1_000: the leader takes 100, the two members 200 each of the equal 400,
159        // and the 500 of the action share goes 1:3:1 between the leader and the members.
160        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        // 10/40/50 of 1_003: leader 100 (100.3), equal 401 (401.2) split in three is 133 each,
178        // actions 501 (501.5) split 2:1 between two members is 334 and 167. Paid: 100 + 399 +
179        // 501 = 1_000; three credits stay in the pot.
180        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        // Nobody acted since the last settle: the 600 of the action share goes 200 each to the
203        // leader and the two members.
204        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        // 2 credits split 0/0/100 by counts 1:1:1 is 0 each: nobody is paid.
232        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        // A member without an action gets nothing of an action-only split.
242        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        // A member set that names the leader counts it once, as the leader.
251        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}