//! The sealed ballot of a vote on the Ğ1 chain (ADR 0008). //! //! Yvv, 2026-10-30: « une personne une voix, mais la voix peut s'exprimer //! plusieurs fois […] c'est le dernier qui fait foi. Pour garantir l'intégrité //! d'un vote, je crois qu'il n'y a que la blockchain, donc une transaction par //! vote. » And: the other votes are seen as totals only. //! //! So a vote is one transaction, signed by the person's wallet: a transfer of //! 0.1 Ğ1 to the vote account and, in the same transaction, a remark that seals //! the ballot — `ld1:` followed by the 64 hex digits of //! `H = blake2b-256(canonical ballot)`. The canonical ballot names the document, //! the account bound to sign it (when known), every line (proposal, revision, //! choice) and a random salt of 128 bits. The chain shows who voted and when, //! never what: without the salt, a choice among three could be guessed. //! //! The chain also orders the votes: **the last one stands**, per proposal and //! per identity, in the order of the chain — block, then rank of the event in //! the block. A ballot confirmed late never overrides a more recent one. //! //! Anyone who sees a remark may copy it. A ballot bound to an account is settled //! by that account's comment only; a ballot bound to none (a Ğecko code) by a //! **paid** comment only — a bare remark costs nothing to send, a transfer of //! the amount does. //! //! Pure: the salt comes from the caller, the facts of the chain too. use crate::address::{self, G1_PREFIX}; use blake2::digest::consts::U32; use blake2::{Blake2b, Digest}; use serde::{Deserialize, Serialize}; /// What every sealed remark starts with: the version of this format. pub const PREFIX: &str = "ld1:"; /// A salt is 16 bytes, written as 32 hex digits. pub const SALT_HEX: usize = 32; /// blake2b with a 256-bit output — the `BlakeTwo256` of Substrate, which hashes a /// remark in the `System.Remarked` event, and the one the indexer keys comments on. pub fn blake2_256(data: &[u8]) -> [u8; 32] { Blake2b::::digest(data).into() } pub fn to_hex(bytes: &[u8]) -> String { bytes.iter().map(|b| format!("{b:02x}")).collect() } #[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)] #[serde(rename_all = "lowercase")] pub enum Choice { For, Against, /// No vote: withdraws an earlier one. None, } impl Choice { pub fn as_str(self) -> &'static str { match self { Self::For => "for", Self::Against => "against", Self::None => "none", } } pub fn parse(raw: &str) -> Option { [Self::For, Self::Against, Self::None].into_iter().find(|c| c.as_str() == raw) } } /// One line of a ballot: a version of an article, the revision voted, the choice. #[derive(Clone, Debug, PartialEq, Eq)] pub struct Line { pub proposal_id: String, pub revision: u32, pub choice: Choice, } #[derive(Debug, PartialEq, Eq)] pub enum BallotError { /// No line, or the same proposal twice. Lines, /// An identifier that would not read back (empty, spaces, line breaks). Identifier, /// The salt is not 32 hex digits. Salt, /// The bound account is not an SS58 address. Author, } /// The ballot as it is hashed: one field per line, the lines in the order of /// their proposal identifiers, the author as a Ğ1 address (or `-`). pub fn canonical(doc_id: &str, author: Option<&str>, lines: &[Line], salt_hex: &str) -> Result { let plain = |s: &str| !s.is_empty() && s.chars().all(|c| c.is_ascii_graphic()); if !plain(doc_id) || lines.iter().any(|l| !plain(&l.proposal_id)) { return Err(BallotError::Identifier); } if salt_hex.len() != SALT_HEX || !salt_hex.bytes().all(|b| b.is_ascii_hexdigit() && !b.is_ascii_uppercase()) { return Err(BallotError::Salt); } let author = match author { Some(a) => address::decode(a).map(|(_, key)| address::encode(G1_PREFIX, &key)).ok_or(BallotError::Author)?, None => "-".to_owned(), }; let mut sorted: Vec<&Line> = lines.iter().collect(); sorted.sort_by(|a, b| a.proposal_id.cmp(&b.proposal_id)); if sorted.is_empty() || sorted.windows(2).any(|w| w[0].proposal_id == w[1].proposal_id) { return Err(BallotError::Lines); } let mut out = format!("ld1\ndoc {doc_id}\nauthor {author}\n"); for l in sorted { out.push_str(&format!("line {} {} {}\n", l.proposal_id, l.revision, l.choice.as_str())); } out.push_str(&format!("salt {salt_hex}\n")); Ok(out) } /// The seal of a ballot, and what the chain sees of it. #[derive(Clone, Debug, PartialEq, Eq)] pub struct Sealed { /// H, 64 hex digits. pub digest: String, /// The remark of the transaction: `ld1:` + H, 68 ASCII bytes. pub comment: String, /// blake2b-256 of the remark's bytes, `0x` + 64 hex digits: what the /// indexer finds the comment by. pub remark_hash: String, } pub fn seal(doc_id: &str, author: Option<&str>, lines: &[Line], salt_hex: &str) -> Result { let text = canonical(doc_id, author, lines, salt_hex)?; let digest = to_hex(&blake2_256(text.as_bytes())); let comment = format!("{PREFIX}{digest}"); let remark_hash = format!("0x{}", to_hex(&blake2_256(comment.as_bytes()))); Ok(Sealed { digest, comment, remark_hash }) } // --------------------------------------------------------------------------- // The order of the chain // --------------------------------------------------------------------------- /// Where an event sits on the chain: its block, then its rank among the events /// of that block. The order of votes. #[derive(Clone, Copy, Debug, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize, Deserialize)] pub struct ChainPosition { pub block: u32, pub event: u32, } impl ChainPosition { /// The identifier the indexer gives an event: `-<5 hex of /// the block hash>-`, e.g. `0003399566-d43e7-000001`. pub fn from_event_id(id: &str) -> Option { let mut parts = id.split('-'); let (block, hash, event) = (parts.next()?, parts.next()?, parts.next()?); if parts.next().is_some() || hash.is_empty() || !hash.bytes().all(|b| b.is_ascii_hexdigit()) { return None; } let digits = |s: &str| !s.is_empty() && s.bytes().all(|b| b.is_ascii_digit()); if !digits(block) || !digits(event) { return None; } Some(Self { block: block.parse().ok()?, event: event.parse().ok()? }) } } /// The last one stands: does a vote at `candidate` decide, given the positions of /// the other confirmed votes of the same identity on the same proposal? Only if /// it comes after all of them. pub fn decides(candidate: ChainPosition, others: &[ChainPosition]) -> bool { others.iter().all(|o| *o < candidate) } // --------------------------------------------------------------------------- // What the chain says of a comment // --------------------------------------------------------------------------- /// A Ğ1 identity, as the chain has it now. #[derive(Clone, Copy, Debug, PartialEq, Eq)] pub struct ChainIdentity { pub index: u32, pub member: bool, } /// A transfer in the same transaction as the comment. #[derive(Clone, Copy, Debug, PartialEq, Eq)] pub struct Transfer { pub from: [u8; 32], pub to: [u8; 32], /// In cents of Ğ1. pub amount: u64, } /// A comment found on the chain with the remark of a ballot. #[derive(Clone, Debug, PartialEq, Eq)] pub struct Comment { pub position: ChainPosition, /// The account that signed the transaction. pub author: [u8; 32], /// The identity whose account it is, if any. pub identity: Option, pub transfers: Vec, } /// What a ballot asks of its transaction. #[derive(Clone, Copy, Debug, PartialEq, Eq)] pub struct Expected { /// The vote account. pub account: [u8; 32], /// At least this many cents. pub amount: u64, /// The account bound to sign, when the page knew it. pub author: Option<[u8; 32]>, } /// What the chain says of a ballot's transaction. Whether a paid ballot counts /// is an access rule — membership of the web of trust, now — that belongs to /// trustinWallet (`trustin_access::chain_vote_counts`). #[derive(Clone, Copy, Debug, PartialEq, Eq)] pub enum Verdict { /// The transaction carries the transfer, and its signer is the account of /// this identity. Paid { identity: ChainIdentity }, /// Signed by another account than the one bound: a copy of the remark, ignored. OtherAuthor, /// A ballot bound to no account (a Ğecko code), and a comment without the /// transfer: a copy of the remark anyone may send for free, ignored. Only a /// paid comment settles such a ballot. Unpaid, /// The account is not that of an identity. NoIdentity, /// No transfer of the amount to the vote account in the same transaction. NoTransfer, } impl Verdict { /// Why a ballot is refused, as stored and translated. pub fn reason(self) -> Option<&'static str> { match self { Self::Paid { .. } | Self::OtherAuthor | Self::Unpaid => None, Self::NoIdentity => Some("no_identity"), Self::NoTransfer => Some("transfer"), } } /// Whether this comment settles the ballot (counted or refused), or is a copy to pass over. pub fn settles(self) -> bool { !matches!(self, Self::OtherAuthor | Self::Unpaid) } } /// Whether the comment's transaction carries the transfer the ballot asks: /// from its signer, to the vote account, at least the amount. pub fn paid(c: &Comment, e: &Expected) -> bool { c.transfers.iter().any(|t| t.from == c.author && t.to == e.account && t.amount >= e.amount) } pub fn verdict(c: &Comment, e: &Expected) -> Verdict { if e.author.is_some_and(|a| a != c.author) { return Verdict::OtherAuthor; } let paid = paid(c, e); if e.author.is_none() && !paid { return Verdict::Unpaid; } let Some(identity) = c.identity else { return Verdict::NoIdentity }; if !paid { return Verdict::NoTransfer; } Verdict::Paid { identity } } /// The comment that settles a ballot: the first in the order of the chain that is /// not a copy — by another account than the one bound, or, unbound, without the /// transfer. `None`: nothing yet, keep waiting. pub fn judge(comments: &[Comment], e: &Expected) -> Option<(ChainPosition, Verdict)> { let mut sorted: Vec<&Comment> = comments.iter().collect(); sorted.sort_by_key(|c| c.position); sorted.into_iter().map(|c| (c.position, verdict(c, e))).find(|(_, v)| v.settles()) } #[cfg(test)] mod tests { use super::*; const ALICE: &str = "g1Pn1geRZnHcVdWdKkoYwQrAARDTXK1D7H3zuXj7GNayT1thu"; const SIMONE: &str = "g1NrqKgJW9FoFabaYW82zhS9tDWdQcL1DGpivaJdj7GW9M6iv"; fn line(p: &str, revision: u32, choice: Choice) -> Line { Line { proposal_id: p.into(), revision, choice } } fn key(addr: &str) -> [u8; 32] { address::decode(addr).expect("address").1 } #[test] fn the_remark_hash_is_the_one_the_chain_publishes() { // Real comment « Merci » of the Ğ1 chain; its hash as the indexer serves it (2026-10-30). assert_eq!(to_hex(&blake2_256(b"Merci ")), "870dc594c5b4daabcc2a3c7149dc2af418376cde62d15cf28a3622fd900c87ce"); } #[test] fn the_canonical_ballot_and_its_seal() { let lines = [line("p2", 1, Choice::Against), line("p1", 3, Choice::For)]; let salt = "00112233445566778899aabbccddeeff"; assert_eq!( canonical("d0c", None, &lines, salt).expect("valid"), "ld1\ndoc d0c\nauthor -\nline p1 3 for\nline p2 1 against\nsalt 00112233445566778899aabbccddeeff\n" ); // Computed apart (Python hashlib.blake2b, digest_size=32). let s = seal("d0c", None, &lines, salt).expect("valid"); assert_eq!(s.digest, "20448d496744292981c8586856afb58a86e37da2470f764b4e6ed84eade71340"); assert_eq!(s.comment, "ld1:20448d496744292981c8586856afb58a86e37da2470f764b4e6ed84eade71340"); assert_eq!(s.comment.len(), 68); assert!(s.comment.is_ascii()); assert_eq!(s.remark_hash, "0xe765221a75716dde863a7b53df3a97acf6db38a2118edd525d54c8a2979b7bc3"); // The order of the lines does not matter; every field does. let swapped = [line("p1", 3, Choice::For), line("p2", 1, Choice::Against)]; assert_eq!(seal("d0c", None, &swapped, salt).expect("valid"), s); for other in [ seal("d0d", None, &lines, salt), seal("d0c", Some(ALICE), &lines, salt), seal("d0c", None, &[line("p2", 2, Choice::Against), line("p1", 3, Choice::For)], salt), seal("d0c", None, &[line("p2", 1, Choice::For), line("p1", 3, Choice::For)], salt), seal("d0c", None, &lines, "00112233445566778899aabbccddeef0"), ] { assert_ne!(other.expect("valid").digest, s.digest); } } #[test] fn a_bound_author_is_written_as_a_g1_address_whatever_its_form() { let lines = [line("p1", 3, Choice::None)]; let salt = "ffeeddccbbaa99887766554433221100"; let s = seal("d0c", Some(SIMONE), &lines, salt).expect("valid"); assert_eq!(s.digest, "6a6d8a90b5003ff3c4d050dbd36199e8bbf34c4cc11620618399226c9237c05e"); assert_eq!(s.remark_hash, "0x846495fe62b9a5184a0d3090d4d26f621887c334890404169c00575ee74540e4"); // The same key in the generic Substrate form (what duniter-connect shows). let generic = seal("d0c", Some("5FwmZc7BSxiLyBDCuB6U6oW9sQNmXk5vNRRYEy8qXxo2FAkn"), &lines, salt); assert_eq!(generic.expect("valid"), s); } #[test] fn what_cannot_be_sealed() { let ok = [line("p1", 1, Choice::For)]; let salt = "00112233445566778899aabbccddeeff"; assert_eq!(seal("d0c", None, &[], salt), Err(BallotError::Lines)); assert_eq!( seal("d0c", None, &[line("p1", 1, Choice::For), line("p1", 2, Choice::Against)], salt), Err(BallotError::Lines) ); assert_eq!(seal("d 0c", None, &ok, salt), Err(BallotError::Identifier)); assert_eq!(seal("d0c", None, &[line("p\n1", 1, Choice::For)], salt), Err(BallotError::Identifier)); assert_eq!(seal("d0c", None, &ok, "0011"), Err(BallotError::Salt)); assert_eq!(seal("d0c", None, &ok, "00112233445566778899AABBCCDDEEFF"), Err(BallotError::Salt)); assert_eq!(seal("d0c", Some("g1Nope"), &ok, salt), Err(BallotError::Author)); } #[test] fn choices_read_back() { for c in [Choice::For, Choice::Against, Choice::None] { assert_eq!(Choice::parse(c.as_str()), Some(c)); } assert_eq!(Choice::parse("abstain"), None); } #[test] fn the_position_of_an_event_reads_in_its_identifier() { assert_eq!( ChainPosition::from_event_id("0003399566-d43e7-000001"), Some(ChainPosition { block: 3_399_566, event: 1 }) ); assert_eq!( ChainPosition::from_event_id("0000196944-60692-000003"), Some(ChainPosition { block: 196_944, event: 3 }) ); for bad in [ "", "0003399566-d43e7", "0003399566-d43e7-000001-2", "x-d43e7-000001", "0003399566--000001", "0003399566-zz-000001", ] { assert_eq!(ChainPosition::from_event_id(bad), None, "{bad}"); } } #[test] fn the_last_one_stands_in_the_order_of_the_chain() { let at = |block, event| ChainPosition { block, event }; // Block first, then the rank of the event in the block. assert!(at(100, 9) < at(101, 0)); assert!(at(100, 2) < at(100, 3)); assert!(decides(at(101, 0), &[])); assert!(decides(at(101, 0), &[at(100, 9), at(100, 2)])); // Confirmed after a more recent one, an older ballot does not override it. assert!(!decides(at(100, 9), &[at(101, 0)])); assert!(!decides(at(100, 2), &[at(100, 3)])); } fn comment(block: u32, author: &str, identity: Option<(u32, bool)>, transfers: &[(&str, &str, u64)]) -> Comment { Comment { position: ChainPosition { block, event: 1 }, author: key(author), identity: identity.map(|(index, member)| ChainIdentity { index, member }), transfers: transfers .iter() .map(|(f, t, amount)| Transfer { from: key(f), to: key(t), amount: *amount }) .collect(), } } const SIMONE_MEMBER: ChainIdentity = ChainIdentity { index: 1304, member: true }; #[test] fn a_ballot_is_paid_by_the_account_of_an_identity() { let e = Expected { account: key(ALICE), amount: 10, author: None }; let paid = [(SIMONE, ALICE, 10)]; let ok = Verdict::Paid { identity: SIMONE_MEMBER }; assert_eq!(verdict(&comment(1, SIMONE, Some((1304, true)), &paid), &e), ok); // More than asked is fine. assert_eq!(verdict(&comment(1, SIMONE, Some((1304, true)), &[(SIMONE, ALICE, 25)]), &e), ok); // Membership is not the engine's to decide (`trustin_access::chain_vote_counts`): it is passed on. let former = Verdict::Paid { identity: ChainIdentity { index: 1304, member: false } }; assert_eq!(verdict(&comment(1, SIMONE, Some((1304, false)), &paid), &e), former); assert_eq!(verdict(&comment(1, SIMONE, None, &paid), &e), Verdict::NoIdentity); // Too little, elsewhere, or nothing: refused when bound to the signer; a copy to pass over when unbound. let bound = Expected { author: Some(key(SIMONE)), ..e }; for unpaid in [&[(SIMONE, ALICE, 9)][..], &[(SIMONE, SIMONE, 10)], &[], &[(ALICE, ALICE, 10)]] { let c = comment(1, SIMONE, Some((1304, true)), unpaid); assert_eq!(verdict(&c, &bound), Verdict::NoTransfer, "{unpaid:?}"); assert_eq!(verdict(&c, &e), Verdict::Unpaid, "{unpaid:?}"); } assert_eq!(Verdict::NoTransfer.reason(), Some("transfer")); assert_eq!(Verdict::NoIdentity.reason(), Some("no_identity")); assert_eq!(ok.reason(), None); assert_eq!(Verdict::Unpaid.reason(), None); assert!(!Verdict::Unpaid.settles() && !Verdict::OtherAuthor.settles() && Verdict::NoTransfer.settles()); } #[test] fn a_copy_of_the_remark_by_another_account_is_ignored_when_the_author_is_bound() { let e = Expected { account: key(ALICE), amount: 10, author: Some(key(SIMONE)) }; let copy = comment(10, ALICE, Some((1, true)), &[(ALICE, ALICE, 10)]); let own = comment(11, SIMONE, Some((1304, true)), &[(SIMONE, ALICE, 10)]); assert_eq!(verdict(©, &e), Verdict::OtherAuthor); // The copy came first on the chain: it is skipped, the ballot counts for its author. assert_eq!( judge(&[own.clone(), copy.clone()], &e), Some((own.position, Verdict::Paid { identity: SIMONE_MEMBER })) ); assert_eq!(judge(&[copy], &e), None, "only a copy: keep waiting"); // Unbound (a Ğecko code): the first on the chain settles it. let free = Expected { author: None, ..e }; let early = comment(9, SIMONE, None, &[(SIMONE, ALICE, 10)]); assert_eq!(judge(&[own, early.clone()], &free), Some((early.position, Verdict::NoIdentity))); assert_eq!(judge(&[], &free), None); } #[test] fn an_unbound_ballot_is_settled_by_a_paid_comment_only() { // A Ğecko code binds no account. A remark alone costs nothing to send: // a bare copy must not settle the ballot (review of 74f96be, P3). let free = Expected { account: key(ALICE), amount: 10, author: None }; let bare = comment(8, SIMONE, None, &[]); let short = comment(9, SIMONE, Some((1304, true)), &[(SIMONE, ALICE, 9)]); let elsewhere = comment(10, ALICE, Some((1, true)), &[(ALICE, SIMONE, 10)]); assert_eq!(judge(&[bare.clone(), short.clone(), elsewhere.clone()], &free), None, "nothing paid: keep waiting"); let paid = comment(11, SIMONE, Some((1304, true)), &[(SIMONE, ALICE, 10)]); assert_eq!( judge(&[paid.clone(), elsewhere, short, bare], &free), Some((paid.position, Verdict::Paid { identity: SIMONE_MEMBER })) ); // Paid by an account without identity: paid, so it settles — refused. let nobody = comment(7, ALICE, None, &[(ALICE, ALICE, 10)]); assert_eq!(judge(&[paid.clone(), nobody.clone()], &free), Some((nobody.position, Verdict::NoIdentity))); // The limit that stays (ADR 0008): a paid copy, earlier on the chain, takes the ballot. let copy = comment(6, ALICE, Some((1, true)), &[(ALICE, ALICE, 10)]); let theirs = ChainIdentity { index: 1, member: true }; assert_eq!(judge(&[paid, copy.clone()], &free), Some((copy.position, Verdict::Paid { identity: theirs }))); // Bound to an account: its own comment without the transfer is refused, and told. let bound = Expected { author: Some(key(SIMONE)), ..free }; let own = comment(12, SIMONE, Some((1304, true)), &[(SIMONE, ALICE, 9)]); assert_eq!(judge(std::slice::from_ref(&own), &bound), Some((own.position, Verdict::NoTransfer))); } }