Skip to main content

dpp/voting/vote_polls/yes_no_vote_poll/
mod.rs

1#[cfg(feature = "json-conversion")]
2use crate::serialization::json_safe_fields;
3#[cfg(feature = "json-conversion")]
4use crate::serialization::JsonConvertible;
5use crate::serialization::PlatformSerializable;
6#[cfg(feature = "value-conversion")]
7use crate::serialization::ValueConvertible;
8use crate::util::hash::hash_double;
9use crate::ProtocolError;
10use bincode::{Decode, DecodeUntrusted, Encode};
11use platform_serialization_derive::{
12    PlatformDeserializeTrusted, PlatformDeserializeUntrusted, PlatformSerialize,
13};
14use platform_value::Identifier;
15use platform_version::version::PlatformVersion;
16#[cfg(feature = "serde-conversion")]
17use serde::{Deserialize, Serialize};
18use std::fmt;
19
20/// Voting power: a masternode vote weighs 1, an evonode vote weighs 4. The sum over the whole
21/// masternode list fits a `u32`.
22pub type VotingPower = u32;
23
24/// Prefixed to the serialized poll before its id is hashed. A contested poll's id hashes its
25/// bare encoding, which starts with a 32-byte contract id; matching this 33-byte tag would take
26/// a contract id equal to its first 32 bytes, so the two kinds never share an id (and with it a
27/// prefunded balance and an end date index entry).
28const YES_NO_VOTE_POLL_ID_DOMAIN: &[u8] = b"dash platform yes/no vote poll id";
29
30/// Which way a fraction of the total voting power rounds to a whole voting power.
31#[derive(Debug, Clone, Copy, PartialEq, Eq, Encode, Decode, DecodeUntrusted)]
32#[cfg_attr(
33    feature = "serde-conversion",
34    derive(Serialize, Deserialize),
35    serde(rename_all = "camelCase")
36)]
37pub enum VotingPowerRounding {
38    /// Toward zero: 1/3 of 10 is 3, 1/3 of 9 is 3.
39    Down,
40    /// Away from zero: 1/3 of 10 is 4, 1/3 of 9 is 3.
41    Up,
42    /// Rounded down, plus one: 1/3 of 10 is 4, 1/3 of 9 is 4. Half the total this way is a
43    /// strict majority of it.
44    DownPlusOne,
45    /// Rounded up, minus one, never below zero: 1/3 of 10 is 3, 1/3 of 9 is 2.
46    UpMinusOne,
47}
48
49/// The least yes plus no voting power a yes/no poll needs to pass.
50///
51/// Its JSON and Value shape is internally tagged, as every sum type's is:
52/// `{ "$type": "absolute", "votingPower": 400 }` or
53/// `{ "$type": "fractionOfTotal", "numerator": 1, "denominator": 3, "rounding": "up" }`.
54#[derive(Debug, Clone, Copy, PartialEq, Eq, Encode, Decode, DecodeUntrusted)]
55#[cfg_attr(
56    feature = "serde-conversion",
57    derive(Serialize, Deserialize),
58    serde(tag = "$type", rename_all = "camelCase")
59)]
60pub enum YesNoMinimumVotingPower {
61    /// A fixed voting power, at most `max_yes_no_vote_poll_minimum_voting_power`.
62    #[cfg_attr(feature = "serde-conversion", serde(rename_all = "camelCase"))]
63    Absolute { voting_power: VotingPower },
64    /// A share of the total voting power of the masternode list (masternode 1, evonode 4) in
65    /// the block that closes the poll, rounded as asked, at most
66    /// `max_yes_no_vote_poll_minimum_voting_power_percent_of_total` percent of it.
67    FractionOfTotal {
68        numerator: u8,
69        denominator: u8,
70        rounding: VotingPowerRounding,
71    },
72}
73
74impl YesNoMinimumVotingPower {
75    /// The minimum as a voting power, given the total voting power of the masternode list when
76    /// the poll closes, as `YesNoVotePoll::required_voting_power` version 0 resolves it. A
77    /// fraction with a zero denominator (refused when the poll opens) needs more than any poll
78    /// can gather.
79    fn resolve_v0(&self, total_voting_power: VotingPower) -> VotingPower {
80        match *self {
81            YesNoMinimumVotingPower::Absolute { voting_power } => voting_power,
82            YesNoMinimumVotingPower::FractionOfTotal {
83                numerator,
84                denominator,
85                rounding,
86            } => {
87                if denominator == 0 {
88                    return VotingPower::MAX;
89                }
90                let share = total_voting_power as u64 * numerator as u64;
91                let denominator = denominator as u64;
92                let resolved = match rounding {
93                    VotingPowerRounding::Down => share / denominator,
94                    VotingPowerRounding::Up => share.div_ceil(denominator),
95                    VotingPowerRounding::DownPlusOne => share / denominator + 1,
96                    VotingPowerRounding::UpMinusOne => {
97                        share.div_ceil(denominator).saturating_sub(1)
98                    }
99                };
100                VotingPower::try_from(resolved).unwrap_or(VotingPower::MAX)
101            }
102        }
103    }
104}
105
106impl fmt::Display for YesNoMinimumVotingPower {
107    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
108        match self {
109            YesNoMinimumVotingPower::Absolute { voting_power } => write!(f, "{}", voting_power),
110            YesNoMinimumVotingPower::FractionOfTotal {
111                numerator,
112                denominator,
113                rounding,
114            } => write!(
115                f,
116                "{}/{} of the total, rounded {}",
117                numerator,
118                denominator,
119                match rounding {
120                    VotingPowerRounding::Down => "down",
121                    VotingPowerRounding::Up => "up",
122                    VotingPowerRounding::DownPlusOne => "down plus one",
123                    VotingPowerRounding::UpMinusOne => "up minus one",
124                }
125            ),
126        }
127    }
128}
129
130/// What a yes/no poll decides: one variant per feature that opens polls.
131///
132/// A poll's id is derived from its purpose alone ([`YesNoVotePoll::unique_id`]), so a purpose
133/// has one poll: a feature that may ask the same question again puts what tells the rounds apart
134/// in its variant. A later feature appends its variant, and the code acting on a finished poll
135/// (`on_yes_no_vote_poll_finished`) matches every variant, so a new one cannot be left out.
136///
137/// Append only: the bincode discriminant is the variant position, and the serialized purpose
138/// names the poll's id, so its storage paths, end date index entry and prefunded balance.
139/// Reordering, removing or reshaping a variant moves every poll opened with it.
140///
141/// Its JSON and Value shape is internally tagged, as every sum type's is:
142/// `{ "$type": "decisionProposal", "documentId": "<base58 id>" }`.
143#[derive(
144    Debug,
145    Clone,
146    Copy,
147    PartialEq,
148    Eq,
149    Encode,
150    Decode,
151    DecodeUntrusted,
152    PlatformSerialize,
153    PlatformDeserializeTrusted,
154    PlatformDeserializeUntrusted,
155)]
156#[cfg_attr(
157    feature = "serde-conversion",
158    derive(Serialize, Deserialize),
159    serde(tag = "$type", rename_all = "camelCase")
160)]
161#[platform_serialize(unversioned)]
162pub enum YesNoVotePollPurpose {
163    /// A decision proposal (protocol version 15): a question anyone puts to the masternodes,
164    /// asked by the proposal document with this id. Signaling only: nothing acts on the result,
165    /// which stays in the poll's stored info.
166    #[cfg_attr(feature = "serde-conversion", serde(rename_all = "camelCase"))]
167    DecisionProposal { document_id: Identifier },
168}
169
170impl fmt::Display for YesNoVotePollPurpose {
171    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
172        match self {
173            YesNoVotePollPurpose::DecisionProposal { document_id } => {
174                write!(f, "decision proposal {}", document_id)
175            }
176        }
177    }
178}
179
180/// A poll the masternodes answer with yes, no or abstain.
181///
182/// The poll passes when the yes voting power is strictly more than
183/// `supermajority_numerator / supermajority_denominator` of the yes plus no voting power, the
184/// abstaining power left out of both sides, and the yes plus no voting power is at least
185/// `minimum_voting_power`. Otherwise it fails. The minimum is a fixed voting power or a share of
186/// the masternode list's total voting power when the poll closes, banned masternodes left out.
187/// The whole rule is carried by the poll so every feature sets its own. Two thirds with a floor
188/// of 400 is numerator 2, denominator 3 and an absolute minimum of 400.
189///
190/// Only the purpose is the poll's identity: [`Self::unique_id`] version 0 is the double SHA-256
191/// of the domain tag `dash platform yes/no vote poll id` followed by the serialized purpose. A
192/// vote names the poll by that id alone; the rule is written to the poll's stored info when it
193/// opens and cannot change after that. The prefunded balance that pays for the votes has the
194/// same id.
195#[cfg_attr(feature = "json-conversion", json_safe_fields)]
196#[cfg_attr(
197    all(feature = "json-conversion", feature = "serde-conversion"),
198    derive(JsonConvertible)
199)]
200#[derive(
201    Debug,
202    Clone,
203    Encode,
204    Decode,
205    PlatformSerialize,
206    PlatformDeserializeTrusted,
207    PlatformDeserializeUntrusted,
208    PartialEq,
209    Eq,
210    DecodeUntrusted,
211)]
212#[cfg_attr(
213    feature = "serde-conversion",
214    derive(Serialize, Deserialize),
215    serde(rename_all = "camelCase")
216)]
217#[cfg_attr(feature = "value-conversion", derive(ValueConvertible))]
218// One attribute: the derives read only the first `platform_serialize` attribute.
219#[platform_serialize(limit = 100000, unversioned)]
220pub struct YesNoVotePoll {
221    /// What the poll decides. The poll's id is derived from it alone.
222    pub purpose: YesNoVotePollPurpose,
223    /// The numerator of the share of yes plus no voting power that yes must exceed.
224    pub supermajority_numerator: u8,
225    /// The denominator of the share of yes plus no voting power that yes must exceed.
226    pub supermajority_denominator: u8,
227    /// The least yes plus no voting power for the poll to pass.
228    pub minimum_voting_power: YesNoMinimumVotingPower,
229}
230
231impl fmt::Display for YesNoVotePoll {
232    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
233        write!(
234            f,
235            "YesNoVotePoll {{ purpose: {}, supermajority: {}/{}, minimum_voting_power: {} }}",
236            self.purpose,
237            self.supermajority_numerator,
238            self.supermajority_denominator,
239            self.minimum_voting_power
240        )
241    }
242}
243
244impl YesNoVotePoll {
245    /// Refuses parameters no poll can be opened with, by the rules the platform version selects
246    /// (`dpp.voting_versions.yes_no_vote_poll_parameters`).
247    ///
248    /// # Parameters
249    /// - `platform_version`: the platform version, whose system limits bound the parameters.
250    ///
251    /// # Returns
252    /// `Ok(())` for parameters a poll can run with, or the error refusing them.
253    pub fn validate_parameters(
254        &self,
255        platform_version: &PlatformVersion,
256    ) -> Result<(), ProtocolError> {
257        match platform_version
258            .dpp
259            .voting_versions
260            .yes_no_vote_poll_parameters
261        {
262            Some(0) => self.validate_parameters_v0(platform_version),
263            Some(version) => Err(ProtocolError::UnknownVersionMismatch {
264                method: "YesNoVotePoll::validate_parameters".to_string(),
265                known_versions: vec![0],
266                received: version,
267            }),
268            None => Err(ProtocolError::UnknownVersionError(
269                "YesNoVotePoll::validate_parameters is not active before protocol version 15"
270                    .to_string(),
271            )),
272        }
273    }
274
275    /// Version 0 refuses a zero denominator, a zero numerator (any single yes would pass
276    /// against every no), a numerator that is not below the denominator (yes could never exceed
277    /// the whole of yes plus no), and a minimum above its cap (a voting power, or a share of the
278    /// total with a zero denominator or above the allowed percentage). The purpose needs no
279    /// check: every variant has a fixed size.
280    fn validate_parameters_v0(
281        &self,
282        platform_version: &PlatformVersion,
283    ) -> Result<(), ProtocolError> {
284        if self.supermajority_denominator == 0 {
285            return Err(ProtocolError::VoteError(
286                "a yes/no vote poll supermajority denominator must not be zero".to_string(),
287            ));
288        }
289        if self.supermajority_numerator == 0 {
290            return Err(ProtocolError::VoteError(
291                "a yes/no vote poll supermajority numerator must not be zero".to_string(),
292            ));
293        }
294        if self.supermajority_numerator >= self.supermajority_denominator {
295            return Err(ProtocolError::VoteError(format!(
296                "a yes/no vote poll supermajority numerator ({}) must be below its denominator ({})",
297                self.supermajority_numerator, self.supermajority_denominator
298            )));
299        }
300        match self.minimum_voting_power {
301            YesNoMinimumVotingPower::Absolute { voting_power } => {
302                let max_voting_power = platform_version
303                    .system_limits
304                    .max_yes_no_vote_poll_minimum_voting_power;
305                if voting_power > max_voting_power {
306                    return Err(ProtocolError::VoteError(format!(
307                        "a yes/no vote poll minimum voting power is at most {}, got {}",
308                        max_voting_power, voting_power
309                    )));
310                }
311            }
312            YesNoMinimumVotingPower::FractionOfTotal {
313                numerator,
314                denominator,
315                ..
316            } => {
317                if denominator == 0 {
318                    return Err(ProtocolError::VoteError(
319                        "a yes/no vote poll minimum share denominator must not be zero".to_string(),
320                    ));
321                }
322                let max_percent = platform_version
323                    .system_limits
324                    .max_yes_no_vote_poll_minimum_voting_power_percent_of_total;
325                if numerator as u32 * 100 > denominator as u32 * max_percent as u32 {
326                    return Err(ProtocolError::VoteError(format!(
327                        "a yes/no vote poll minimum share is at most {}% of the total voting power, got {}/{}",
328                        max_percent, numerator, denominator
329                    )));
330                }
331            }
332        }
333        Ok(())
334    }
335
336    /// The least yes plus no voting power the poll needs, given the total voting power of the
337    /// masternode list in the block that closes it, by the rule the platform version selects
338    /// (`dpp.voting_versions.yes_no_vote_poll_decision`).
339    ///
340    /// # Parameters
341    /// - `total_voting_power`: the masternode list's total voting power in the closing block.
342    /// - `platform_version`: the platform version.
343    ///
344    /// # Returns
345    /// The required voting power.
346    pub fn required_voting_power(
347        &self,
348        total_voting_power: VotingPower,
349        platform_version: &PlatformVersion,
350    ) -> Result<VotingPower, ProtocolError> {
351        match platform_version
352            .dpp
353            .voting_versions
354            .yes_no_vote_poll_decision
355        {
356            Some(0) => Ok(self.required_voting_power_v0(total_voting_power)),
357            Some(version) => Err(ProtocolError::UnknownVersionMismatch {
358                method: "YesNoVotePoll::required_voting_power".to_string(),
359                known_versions: vec![0],
360                received: version,
361            }),
362            None => Err(ProtocolError::UnknownVersionError(
363                "YesNoVotePoll::required_voting_power is not active before protocol version 15"
364                    .to_string(),
365            )),
366        }
367    }
368
369    /// Whether the poll passes with these final tallies, given the total voting power of the
370    /// masternode list in the block that closes it, by the rule the platform version selects
371    /// (`dpp.voting_versions.yes_no_vote_poll_decision`). Abstaining power plays no part.
372    ///
373    /// # Parameters
374    /// - `yes_voting_power`: the yes voting power cast.
375    /// - `no_voting_power`: the no voting power cast.
376    /// - `total_voting_power`: the masternode list's total voting power in the closing block.
377    /// - `platform_version`: the platform version.
378    ///
379    /// # Returns
380    /// Whether the poll passes.
381    pub fn passes(
382        &self,
383        yes_voting_power: VotingPower,
384        no_voting_power: VotingPower,
385        total_voting_power: VotingPower,
386        platform_version: &PlatformVersion,
387    ) -> Result<bool, ProtocolError> {
388        match platform_version
389            .dpp
390            .voting_versions
391            .yes_no_vote_poll_decision
392        {
393            Some(0) => Ok(self.passes_v0(yes_voting_power, no_voting_power, total_voting_power)),
394            Some(version) => Err(ProtocolError::UnknownVersionMismatch {
395                method: "YesNoVotePoll::passes".to_string(),
396                known_versions: vec![0],
397                received: version,
398            }),
399            None => Err(ProtocolError::UnknownVersionError(
400                "YesNoVotePoll::passes is not active before protocol version 15".to_string(),
401            )),
402        }
403    }
404
405    /// Version 0: the minimum resolved against the total, rounded as the poll asks.
406    fn required_voting_power_v0(&self, total_voting_power: VotingPower) -> VotingPower {
407        self.minimum_voting_power.resolve_v0(total_voting_power)
408    }
409
410    /// Version 0: yes plus no must reach the required voting power, and yes must be strictly
411    /// more than the supermajority share of yes plus no.
412    fn passes_v0(
413        &self,
414        yes_voting_power: VotingPower,
415        no_voting_power: VotingPower,
416        total_voting_power: VotingPower,
417    ) -> bool {
418        let yes = yes_voting_power as u64;
419        let cast = yes + no_voting_power as u64;
420        if cast < self.required_voting_power_v0(total_voting_power) as u64 {
421            return false;
422        }
423        yes * self.supermajority_denominator as u64 > cast * self.supermajority_numerator as u64
424    }
425
426    /// The id the poll is stored and voted on under, which also keys its end date index entry
427    /// and names its prefunded balance, derived as the platform version selects
428    /// (`dpp.voting_versions.yes_no_vote_poll_id`).
429    ///
430    /// # Parameters
431    /// - `platform_version`: the platform version.
432    ///
433    /// # Returns
434    /// The poll's id.
435    pub fn unique_id(
436        &self,
437        platform_version: &PlatformVersion,
438    ) -> Result<Identifier, ProtocolError> {
439        match platform_version.dpp.voting_versions.yes_no_vote_poll_id {
440            Some(0) => self.unique_id_v0(),
441            Some(version) => Err(ProtocolError::UnknownVersionMismatch {
442                method: "YesNoVotePoll::unique_id".to_string(),
443                known_versions: vec![0],
444                received: version,
445            }),
446            None => Err(ProtocolError::UnknownVersionError(
447                "YesNoVotePoll::unique_id is not active before protocol version 15".to_string(),
448            )),
449        }
450    }
451
452    /// The prefunded balance the votes on this poll are paid from: the poll's unique id.
453    ///
454    /// # Parameters
455    /// - `platform_version`: the platform version, which selects how the id is derived.
456    ///
457    /// # Returns
458    /// The balance's id.
459    pub fn specialized_balance_id(
460        &self,
461        platform_version: &PlatformVersion,
462    ) -> Result<Identifier, ProtocolError> {
463        self.unique_id(platform_version)
464    }
465
466    /// Version 0: the double SHA-256 of the yes/no poll id domain tag followed by the serialized
467    /// purpose. The rule is left out, so a vote naming the id answers whatever rule the poll
468    /// opened with, and a purpose opens one poll whatever rule it is asked with.
469    fn unique_id_v0(&self) -> Result<Identifier, ProtocolError> {
470        let mut preimage = YES_NO_VOTE_POLL_ID_DOMAIN.to_vec();
471        preimage.extend(self.purpose.serialize_to_bytes()?);
472        Ok(Identifier::new(hash_double(preimage)))
473    }
474}
475
476#[cfg(test)]
477mod tests {
478    use super::*;
479
480    fn poll() -> YesNoVotePoll {
481        YesNoVotePoll {
482            purpose: YesNoVotePollPurpose::DecisionProposal {
483                document_id: Identifier::new([0xc1; 32]),
484            },
485            supermajority_numerator: 2,
486            supermajority_denominator: 3,
487            minimum_voting_power: YesNoMinimumVotingPower::Absolute { voting_power: 400 },
488        }
489    }
490
491    #[test]
492    fn should_pass_strictly_above_two_thirds_and_fail_at_or_below_it() {
493        // The minimum is met by every tally below; only the share decides.
494        let poll = YesNoVotePoll {
495            minimum_voting_power: YesNoMinimumVotingPower::Absolute { voting_power: 300 },
496            ..poll()
497        };
498        // 300 cast: two thirds is 200, so 201 passes and 200 does not.
499        assert!(poll.passes_v0(201, 99, 0));
500        assert!(!poll.passes_v0(200, 100, 0));
501        // 301 cast: two thirds is 200.67, so 201 passes and 200 does not.
502        assert!(poll.passes_v0(201, 100, 0));
503        assert!(!poll.passes_v0(200, 101, 0));
504    }
505
506    #[test]
507    fn should_fail_below_the_minimum_voting_power_even_when_unanimous() {
508        let poll = poll();
509        assert!(!poll.passes_v0(399, 0, 0));
510        assert!(poll.passes_v0(400, 0, 0));
511    }
512
513    #[test]
514    fn should_resolve_a_share_of_the_total_rounded_as_asked() {
515        let third = |rounding| YesNoMinimumVotingPower::FractionOfTotal {
516            numerator: 1,
517            denominator: 3,
518            rounding,
519        };
520        assert_eq!(third(VotingPowerRounding::Down).resolve_v0(10), 3);
521        assert_eq!(third(VotingPowerRounding::Up).resolve_v0(10), 4);
522        // An exact share is the same either way.
523        assert_eq!(third(VotingPowerRounding::Down).resolve_v0(9), 3);
524        assert_eq!(third(VotingPowerRounding::Up).resolve_v0(9), 3);
525        assert_eq!(third(VotingPowerRounding::Up).resolve_v0(0), 0);
526        // Rounded down plus one, and rounded up minus one (never below zero).
527        assert_eq!(third(VotingPowerRounding::DownPlusOne).resolve_v0(10), 4);
528        assert_eq!(third(VotingPowerRounding::DownPlusOne).resolve_v0(9), 4);
529        assert_eq!(third(VotingPowerRounding::DownPlusOne).resolve_v0(0), 1);
530        assert_eq!(third(VotingPowerRounding::UpMinusOne).resolve_v0(10), 3);
531        assert_eq!(third(VotingPowerRounding::UpMinusOne).resolve_v0(9), 2);
532        assert_eq!(third(VotingPowerRounding::UpMinusOne).resolve_v0(0), 0);
533        // Half rounded down plus one is a strict majority.
534        let majority = YesNoMinimumVotingPower::FractionOfTotal {
535            numerator: 1,
536            denominator: 2,
537            rounding: VotingPowerRounding::DownPlusOne,
538        };
539        assert_eq!(majority.resolve_v0(10), 6);
540        assert_eq!(majority.resolve_v0(9), 5);
541        assert_eq!(majority.resolve_v0(u32::MAX), u32::MAX / 2 + 1);
542        assert_eq!(
543            YesNoMinimumVotingPower::Absolute { voting_power: 400 }.resolve_v0(10),
544            400
545        );
546        // No overflow on the largest total.
547        let half = YesNoMinimumVotingPower::FractionOfTotal {
548            numerator: 1,
549            denominator: 2,
550            rounding: VotingPowerRounding::Up,
551        };
552        assert_eq!(half.resolve_v0(u32::MAX), u32::MAX / 2 + 1);
553    }
554
555    #[test]
556    fn should_judge_a_share_minimum_against_the_total_at_close() {
557        // Two thirds with at least 1/10 of the total cast, rounded up.
558        let poll = YesNoVotePoll {
559            minimum_voting_power: YesNoMinimumVotingPower::FractionOfTotal {
560                numerator: 1,
561                denominator: 10,
562                rounding: VotingPowerRounding::Up,
563            },
564            ..poll()
565        };
566        // A total of 3,801 needs 381 cast.
567        assert_eq!(poll.required_voting_power_v0(3_801), 381);
568        assert!(!poll.passes_v0(380, 0, 3_801));
569        assert!(poll.passes_v0(381, 0, 3_801));
570        // The same tally fails once the list has grown.
571        assert!(!poll.passes_v0(381, 0, 3_811));
572    }
573
574    #[test]
575    fn should_bound_the_minimum() {
576        let platform_version = PlatformVersion::latest();
577        let max_voting_power = platform_version
578            .system_limits
579            .max_yes_no_vote_poll_minimum_voting_power;
580        let with_minimum = |minimum_voting_power| YesNoVotePoll {
581            minimum_voting_power,
582            ..poll()
583        };
584        let share = |numerator, denominator| YesNoMinimumVotingPower::FractionOfTotal {
585            numerator,
586            denominator,
587            rounding: VotingPowerRounding::Up,
588        };
589        for accepted in [
590            YesNoMinimumVotingPower::Absolute { voting_power: 0 },
591            YesNoMinimumVotingPower::Absolute {
592                voting_power: max_voting_power,
593            },
594            share(0, 1),
595            share(1, 3),
596            share(1, 2),
597            share(50, 100),
598        ] {
599            assert!(
600                with_minimum(accepted)
601                    .validate_parameters(platform_version)
602                    .is_ok(),
603                "{accepted} should be accepted"
604            );
605        }
606        for refused in [
607            YesNoMinimumVotingPower::Absolute {
608                voting_power: max_voting_power + 1,
609            },
610            share(1, 0),
611            share(51, 100),
612            share(2, 3),
613        ] {
614            assert!(
615                with_minimum(refused)
616                    .validate_parameters(platform_version)
617                    .is_err(),
618                "{refused} should be refused"
619            );
620        }
621    }
622
623    #[test]
624    fn should_not_overflow_on_the_largest_tallies() {
625        let poll = YesNoVotePoll {
626            supermajority_numerator: 254,
627            supermajority_denominator: 255,
628            ..poll()
629        };
630        assert!(poll.passes_v0(u32::MAX, 0, 0));
631        assert!(!poll.passes_v0(u32::MAX, u32::MAX, 0));
632    }
633
634    #[test]
635    fn should_refuse_parameters_no_poll_can_run_with() {
636        let platform_version = PlatformVersion::latest();
637        assert!(poll().validate_parameters(platform_version).is_ok());
638        assert!(YesNoVotePoll {
639            supermajority_denominator: 0,
640            ..poll()
641        }
642        .validate_parameters(platform_version)
643        .is_err());
644        assert!(YesNoVotePoll {
645            supermajority_numerator: 3,
646            supermajority_denominator: 3,
647            ..poll()
648        }
649        .validate_parameters(platform_version)
650        .is_err());
651        // A zero numerator would let one yes pass against any amount of no.
652        assert!(YesNoVotePoll {
653            supermajority_numerator: 0,
654            ..poll()
655        }
656        .validate_parameters(platform_version)
657        .is_err());
658        // A simple majority is a valid rule.
659        assert!(YesNoVotePoll {
660            supermajority_numerator: 1,
661            supermajority_denominator: 2,
662            ..poll()
663        }
664        .validate_parameters(platform_version)
665        .is_ok());
666    }
667
668    #[test]
669    fn should_derive_the_id_from_the_purpose_alone() {
670        let platform_version = PlatformVersion::latest();
671        let base = poll().unique_id(platform_version).expect("id");
672        let other_purpose = YesNoVotePoll {
673            purpose: YesNoVotePollPurpose::DecisionProposal {
674                document_id: Identifier::new([0xc2; 32]),
675            },
676            ..poll()
677        };
678        assert_ne!(other_purpose.unique_id(platform_version).expect("id"), base);
679        // The rule is not part of the id: the same purpose asked with another rule names the
680        // same poll, which then cannot be opened a second time.
681        let other_rule = YesNoVotePoll {
682            supermajority_numerator: 1,
683            supermajority_denominator: 2,
684            ..poll()
685        };
686        let other_minimum = YesNoVotePoll {
687            minimum_voting_power: YesNoMinimumVotingPower::Absolute { voting_power: 401 },
688            ..poll()
689        };
690        for same in [other_rule, other_minimum] {
691            assert_eq!(same.unique_id(platform_version).expect("id"), base);
692        }
693        assert_eq!(
694            poll()
695                .specialized_balance_id(platform_version)
696                .expect("balance id"),
697            base
698        );
699    }
700
701    #[test]
702    fn should_hash_the_id_under_the_yes_no_domain_tag() {
703        let encoded = poll()
704            .purpose
705            .serialize_to_bytes()
706            .expect("encoded purpose");
707        // A tag byte for the variant, then the 32-byte document id. `DecisionProposal` is
708        // variant 0: reordering the purposes would move every decision proposal's poll.
709        assert_eq!(encoded, [[0u8].as_slice(), &[0xc1; 32]].concat());
710        let untagged = Identifier::new(hash_double(&encoded));
711        let tagged = Identifier::new(hash_double(
712            [YES_NO_VOTE_POLL_ID_DOMAIN, encoded.as_slice()].concat(),
713        ));
714        let id = poll().unique_id(PlatformVersion::latest()).expect("id");
715        assert_eq!(id, tagged);
716        assert_ne!(id, untagged);
717    }
718
719    #[test]
720    fn should_report_the_poll_rules_not_active_before_protocol_version_15() {
721        let platform_version = PlatformVersion::get(14).expect("expected protocol version 14");
722        let not_active = |result: Result<(), ProtocolError>, method: &str| {
723            assert!(
724                matches!(result, Err(ProtocolError::UnknownVersionError(_))),
725                "expected {method} to be not active at protocol version 14, got {result:?}"
726            );
727        };
728        not_active(
729            poll().validate_parameters(platform_version),
730            "validate_parameters",
731        );
732        not_active(
733            poll()
734                .required_voting_power(100, platform_version)
735                .map(|_| ()),
736            "required_voting_power",
737        );
738        not_active(
739            poll().passes(10, 0, 100, platform_version).map(|_| ()),
740            "passes",
741        );
742        not_active(poll().unique_id(platform_version).map(|_| ()), "unique_id");
743        not_active(
744            poll().specialized_balance_id(platform_version).map(|_| ()),
745            "specialized_balance_id",
746        );
747    }
748}
749
750#[cfg(all(
751    test,
752    feature = "json-conversion",
753    feature = "value-conversion",
754    feature = "serde-conversion"
755))]
756mod json_convertible_tests {
757    use super::*;
758    use platform_value::platform_value;
759    use serde_json::json;
760
761    fn fixture() -> YesNoVotePoll {
762        YesNoVotePoll {
763            purpose: YesNoVotePollPurpose::DecisionProposal {
764                document_id: Identifier::new([0xc1; 32]),
765            },
766            supermajority_numerator: 2,
767            supermajority_denominator: 3,
768            minimum_voting_power: YesNoMinimumVotingPower::Absolute { voting_power: 400 },
769        }
770    }
771
772    #[test]
773    fn json_round_trip_with_full_wire_shape() {
774        use crate::serialization::JsonConvertible;
775        let original = fixture();
776        let json = original.to_json().expect("to_json");
777        // The purpose is internally tagged; its document id is a base58 string in JSON.
778        assert_eq!(
779            json,
780            json!({
781                "purpose": {
782                    "$type": "decisionProposal",
783                    "documentId": Identifier::new([0xc1; 32])
784                        .to_string(platform_value::string_encoding::Encoding::Base58),
785                },
786                "supermajorityNumerator": 2,
787                "supermajorityDenominator": 3,
788                "minimumVotingPower": { "$type": "absolute", "votingPower": 400 },
789            })
790        );
791        let recovered = YesNoVotePoll::from_json(json).expect("from_json");
792        assert_eq!(original, recovered);
793    }
794
795    #[test]
796    fn should_round_trip_a_share_minimum_through_json() {
797        use crate::serialization::JsonConvertible;
798        let original = YesNoVotePoll {
799            minimum_voting_power: YesNoMinimumVotingPower::FractionOfTotal {
800                numerator: 1,
801                denominator: 3,
802                rounding: VotingPowerRounding::Up,
803            },
804            ..fixture()
805        };
806        let json = original.to_json().expect("to_json");
807        assert_eq!(
808            json["minimumVotingPower"],
809            json!({ "$type": "fractionOfTotal", "numerator": 1, "denominator": 3, "rounding": "up" })
810        );
811        let recovered = YesNoVotePoll::from_json(json).expect("from_json");
812        assert_eq!(original, recovered);
813    }
814
815    #[test]
816    fn should_name_every_rounding_in_camel_case() {
817        use crate::serialization::JsonConvertible;
818        for (rounding, name) in [
819            (VotingPowerRounding::Down, "down"),
820            (VotingPowerRounding::Up, "up"),
821            (VotingPowerRounding::DownPlusOne, "downPlusOne"),
822            (VotingPowerRounding::UpMinusOne, "upMinusOne"),
823        ] {
824            let original = YesNoVotePoll {
825                minimum_voting_power: YesNoMinimumVotingPower::FractionOfTotal {
826                    numerator: 1,
827                    denominator: 2,
828                    rounding,
829                },
830                ..fixture()
831            };
832            let json = original.to_json().expect("to_json");
833            assert_eq!(json["minimumVotingPower"]["rounding"], json!(name));
834            assert_eq!(YesNoVotePoll::from_json(json).expect("from_json"), original);
835        }
836    }
837
838    #[test]
839    fn value_round_trip_with_full_wire_shape() {
840        use crate::serialization::ValueConvertible;
841        let original = fixture();
842        let value = original.to_object().expect("to_object");
843        assert_eq!(
844            value,
845            platform_value!({
846                "purpose": {
847                    "$type": "decisionProposal",
848                    "documentId": Identifier::new([0xc1; 32]),
849                },
850                "supermajorityNumerator": 2u8,
851                "supermajorityDenominator": 3u8,
852                "minimumVotingPower": { "$type": "absolute", "votingPower": 400u32 },
853            })
854        );
855        let recovered = YesNoVotePoll::from_object(value).expect("from_object");
856        assert_eq!(original, recovered);
857    }
858}