forked from yvv/decision
v3 : le vote par la chaîne Ğ1, côté moteur et serveur
Le bulletin scellé (ld1: + blake2b-256 du bulletin canonique, sel de 128 bits), l'ordre de la chaîne (bloc, puis rang de l'événement : le dernier fait foi), les adresses SS58 comparées par clé (ld-engine, pur). trustin-access : Monnaie libre vote par la chaîne, les groupes gérés sur le serveur ; le droit de vote se lit à la confirmation. Serveur : bulletins en attente, confirmation sur l'indexeur (virement, auteur, identité, membre), identité idty:<index> créée au besoin, tâche de fond toutes les 10 s, expiration à 24 h, vote groupé en une écriture dans les groupes gérés. Migration additive 0006. Sans LD_VOTE_ACCOUNT, le vote par la chaîne est éteint. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
@@ -7,6 +7,7 @@ rust-version.workspace = true
|
||||
publish.workspace = true
|
||||
|
||||
[dependencies]
|
||||
blake2 = "0.10"
|
||||
serde = { version = "1", features = ["derive"] }
|
||||
unicode-normalization = "0.1"
|
||||
|
||||
|
||||
@@ -0,0 +1,160 @@
|
||||
//! Ğ1 account addresses (SS58).
|
||||
//!
|
||||
//! The same public key reads `g1…` on the Ğ1 chain (prefix 4450, what the
|
||||
//! indexer and Ğecko show) and `5…` in the generic Substrate form (what
|
||||
//! duniter-connect shows). Accounts are compared by key, never by text.
|
||||
|
||||
/// The SS58 prefix of the Ğ1 chain.
|
||||
pub const G1_PREFIX: u16 = 4450;
|
||||
|
||||
const ALPHABET: &[u8; 58] = b"123456789ABCDEFGHJKLMNPQRSTUVWXYZabcdefghijkmnopqrstuvwxyz";
|
||||
|
||||
fn base58_decode(s: &str) -> Option<Vec<u8>> {
|
||||
// Big-endian bytes of the number, grown symbol by symbol.
|
||||
let mut number: Vec<u8> = Vec::new();
|
||||
for c in s.bytes() {
|
||||
let mut carry = u32::try_from(ALPHABET.iter().position(|a| *a == c)?).ok()?;
|
||||
for b in number.iter_mut().rev() {
|
||||
carry += u32::from(*b) * 58;
|
||||
*b = (carry & 0xff) as u8;
|
||||
carry >>= 8;
|
||||
}
|
||||
while carry > 0 {
|
||||
number.insert(0, (carry & 0xff) as u8);
|
||||
carry >>= 8;
|
||||
}
|
||||
}
|
||||
let mut out = vec![0u8; s.bytes().take_while(|c| *c == b'1').count()];
|
||||
out.extend(number);
|
||||
Some(out)
|
||||
}
|
||||
|
||||
fn base58_encode(data: &[u8]) -> String {
|
||||
// Little-endian base-58 symbols.
|
||||
let mut symbols: Vec<u8> = Vec::new();
|
||||
for byte in data {
|
||||
let mut carry = u32::from(*byte);
|
||||
for d in symbols.iter_mut() {
|
||||
carry += u32::from(*d) << 8;
|
||||
*d = (carry % 58) as u8;
|
||||
carry /= 58;
|
||||
}
|
||||
while carry > 0 {
|
||||
symbols.push((carry % 58) as u8);
|
||||
carry /= 58;
|
||||
}
|
||||
}
|
||||
let zeros = data.iter().take_while(|b| **b == 0).count();
|
||||
std::iter::repeat_n('1', zeros).chain(symbols.iter().rev().map(|d| char::from(ALPHABET[usize::from(*d)]))).collect()
|
||||
}
|
||||
|
||||
/// The two bytes that close an address: blake2b-512 of `SS58PRE`, the prefix and the key.
|
||||
fn checksum(payload: &[u8]) -> [u8; 2] {
|
||||
use blake2::{Blake2b512, Digest};
|
||||
let mut h = Blake2b512::new();
|
||||
h.update(b"SS58PRE");
|
||||
h.update(payload);
|
||||
let out = h.finalize();
|
||||
[out[0], out[1]]
|
||||
}
|
||||
|
||||
fn prefix_bytes(prefix: u16) -> Vec<u8> {
|
||||
if prefix < 64 {
|
||||
vec![prefix as u8]
|
||||
} else {
|
||||
// Two bytes for the prefixes 64 to 16383, as Substrate writes them.
|
||||
vec![
|
||||
(((prefix & 0b1111_1100) >> 2) as u8) | 0b0100_0000,
|
||||
((prefix >> 8) as u8) | (((prefix & 0b11) as u8) << 6),
|
||||
]
|
||||
}
|
||||
}
|
||||
|
||||
/// The prefix and the 32-byte public key of an SS58 address, if it is one.
|
||||
pub fn decode(addr: &str) -> Option<(u16, [u8; 32])> {
|
||||
let data = base58_decode(addr)?;
|
||||
let (prefix, at) = match *data.first()? {
|
||||
b @ 0..=63 => (u16::from(b), 1),
|
||||
b @ 64..=127 => {
|
||||
let b1 = *data.get(1)?;
|
||||
let lower = ((b & 0b0011_1111) << 2) | (b1 >> 6);
|
||||
let upper = b1 & 0b0011_1111;
|
||||
(u16::from(lower) | (u16::from(upper) << 8), 2)
|
||||
}
|
||||
_ => return None,
|
||||
};
|
||||
if data.len() != at + 32 + 2 {
|
||||
return None;
|
||||
}
|
||||
let (payload, sum) = data.split_at(at + 32);
|
||||
if checksum(payload) != sum {
|
||||
return None;
|
||||
}
|
||||
Some((prefix, payload[at..].try_into().ok()?))
|
||||
}
|
||||
|
||||
/// The SS58 address of a public key under a prefix (up to 16383).
|
||||
pub fn encode(prefix: u16, key: &[u8; 32]) -> String {
|
||||
let mut data = prefix_bytes(prefix & 0x3fff);
|
||||
data.extend_from_slice(key);
|
||||
let sum = checksum(&data);
|
||||
data.extend_from_slice(&sum);
|
||||
base58_encode(&data)
|
||||
}
|
||||
|
||||
/// The Ğ1 form of an address in any SS58 form.
|
||||
pub fn to_g1(addr: &str) -> Option<String> {
|
||||
decode(addr).map(|(_, key)| encode(G1_PREFIX, &key))
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
fn hex32(s: &str) -> [u8; 32] {
|
||||
let bytes: Vec<u8> = (0..64).step_by(2).map(|i| u8::from_str_radix(&s[i..i + 2], 16).expect("hex")).collect();
|
||||
bytes.try_into().expect("32 bytes")
|
||||
}
|
||||
|
||||
// Computed with @polkadot/util-crypto (decodeAddress / encodeAddress), 2026-10-30.
|
||||
const VECTORS: [(&str, &str, &str); 3] = [
|
||||
(
|
||||
"g1NrqKgJW9FoFabaYW82zhS9tDWdQcL1DGpivaJdj7GW9M6iv",
|
||||
"aba6b0dcf21a0ddff2e87b52225ddddf06e8c83afb7cd874e7438dd9aa7c2ee8",
|
||||
"5FwmZc7BSxiLyBDCuB6U6oW9sQNmXk5vNRRYEy8qXxo2FAkn",
|
||||
),
|
||||
(
|
||||
"g1LQM6MfnjdPTrgz3ubwhwnPdbWUUq2GGQCqRQuGgrke9vRMA",
|
||||
"3efad05b01361fe102ce8bff5e5fd1be7554af684c7e741aaab256cdf950b196",
|
||||
"5DVHLHUU3LJZFGciJf1BM9juFQDqkSLyVoY35ZmoHSw2pZ1s",
|
||||
),
|
||||
(
|
||||
"g1Pn1geRZnHcVdWdKkoYwQrAARDTXK1D7H3zuXj7GNayT1thu",
|
||||
"d43593c715fdd31c61141abd04a99fd6822c8558854ccde39a5684e7a56da27d",
|
||||
"5GrwvaEF5zXb26Fz9rcQpDWS57CtERHpNehXCPcNoHGKutQY",
|
||||
),
|
||||
];
|
||||
|
||||
#[test]
|
||||
fn g1_and_generic_forms_of_the_same_key() {
|
||||
for (g1, key, generic) in VECTORS {
|
||||
assert_eq!(decode(g1), Some((G1_PREFIX, hex32(key))), "{g1}");
|
||||
assert_eq!(decode(generic), Some((42, hex32(key))), "{generic}");
|
||||
assert_eq!(encode(G1_PREFIX, &hex32(key)), g1);
|
||||
assert_eq!(encode(42, &hex32(key)), generic);
|
||||
assert_eq!(to_g1(generic).as_deref(), Some(g1));
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn what_is_not_an_address() {
|
||||
let (g1, _, _) = VECTORS[0];
|
||||
// One character changed: the checksum no longer holds.
|
||||
let mut tampered = g1.to_owned();
|
||||
tampered.replace_range(10..11, if &g1[10..11] == "a" { "b" } else { "a" });
|
||||
for bad in ["", "g1", "0OIl", &tampered, &g1[..g1.len() - 1], "5GrwvaEF5zXb26Fz9rcQpDWS57CtERHpNehXCPcNoHGKutQ"]
|
||||
{
|
||||
assert_eq!(decode(bad), None, "{bad}");
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,432 @@
|
||||
//! 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.
|
||||
//!
|
||||
//! 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::<U32>::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> {
|
||||
[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<String, BallotError> {
|
||||
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<Sealed, BallotError> {
|
||||
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: `<block, 10 digits>-<5 hex of
|
||||
/// the block hash>-<rank of the event, 6 digits>`, e.g. `0003399566-d43e7-000001`.
|
||||
pub fn from_event_id(id: &str) -> Option<Self> {
|
||||
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<ChainIdentity>,
|
||||
pub transfers: Vec<Transfer>,
|
||||
}
|
||||
|
||||
/// 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,
|
||||
/// 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 => None,
|
||||
Self::NoIdentity => Some("no_identity"),
|
||||
Self::NoTransfer => Some("transfer"),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub fn verdict(c: &Comment, e: &Expected) -> Verdict {
|
||||
if e.author.is_some_and(|a| a != c.author) {
|
||||
return Verdict::OtherAuthor;
|
||||
}
|
||||
let Some(identity) = c.identity else { return Verdict::NoIdentity };
|
||||
if !c.transfers.iter().any(|t| t.from == c.author && t.to == e.account && t.amount >= e.amount) {
|
||||
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. `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 != Verdict::OtherAuthor)
|
||||
}
|
||||
|
||||
#[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.
|
||||
assert_eq!(verdict(&comment(1, SIMONE, Some((1304, true)), &[(SIMONE, ALICE, 9)]), &e), Verdict::NoTransfer);
|
||||
assert_eq!(verdict(&comment(1, SIMONE, Some((1304, true)), &[(SIMONE, SIMONE, 10)]), &e), Verdict::NoTransfer);
|
||||
assert_eq!(verdict(&comment(1, SIMONE, Some((1304, true)), &[]), &e), Verdict::NoTransfer);
|
||||
// Paid from another account than the signer's.
|
||||
assert_eq!(verdict(&comment(1, SIMONE, Some((1304, true)), &[(ALICE, ALICE, 10)]), &e), Verdict::NoTransfer);
|
||||
assert_eq!(Verdict::NoTransfer.reason(), Some("transfer"));
|
||||
assert_eq!(Verdict::NoIdentity.reason(), Some("no_identity"));
|
||||
assert_eq!(ok.reason(), None);
|
||||
}
|
||||
|
||||
#[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);
|
||||
}
|
||||
}
|
||||
@@ -6,7 +6,9 @@
|
||||
//! separates a typo fix from a change of meaning, the rule-based editing
|
||||
//! assistant, and the composition of a document under permanent vote.
|
||||
|
||||
pub mod address;
|
||||
pub mod assist;
|
||||
pub mod ballot;
|
||||
pub mod compose;
|
||||
pub mod edit;
|
||||
pub mod inertia;
|
||||
|
||||
Reference in New Issue
Block a user