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:
Yvv
2026-10-30 20:52:47 +01:00
co-authored by Claude Opus 5.5
parent a576b68570
commit 3541e1b1a6
19 changed files with 2595 additions and 105 deletions
Generated
+10
View File
@@ -153,6 +153,15 @@ dependencies = [
"serde_core",
]
[[package]]
name = "blake2"
version = "0.10.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "46502ad458c9a52b69d4d4d32775c788b7a1b85e8bc9d482d92250fc0e3f8efe"
dependencies = [
"digest",
]
[[package]]
name = "block-buffer"
version = "0.10.4"
@@ -884,6 +893,7 @@ dependencies = [
name = "ld-engine"
version = "0.3.0"
dependencies = [
"blake2",
"serde",
"serde_json",
"unicode-normalization",
+1
View File
@@ -7,6 +7,7 @@ rust-version.workspace = true
publish.workspace = true
[dependencies]
blake2 = "0.10"
serde = { version = "1", features = ["derive"] }
unicode-normalization = "0.1"
+160
View File
@@ -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}");
}
}
}
+432
View File
@@ -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(&copy, &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);
}
}
+2
View File
@@ -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;
@@ -0,0 +1,51 @@
-- The vote on the Ğ1 chain (2026-10-30, ADR 0008). Additive only: nothing is
-- rewritten, every vote of before stays as it was (source 'server').
--
-- A ballot is asked by a session, sealed by the server (salt, digest H) and
-- signed by a wallet: one transaction, a transfer to the vote account and the
-- remark `ld1:<H>`. The server confirms it by reading the indexer, then writes
-- each of its lines into `votes` for the Ğ1 identity of the signer — unless a
-- more recent ballot of that identity, in the order of the chain, already
-- decided that proposal (the last one stands).
CREATE TABLE ballots (
id TEXT PRIMARY KEY,
doc_id TEXT NOT NULL REFERENCES documents(id),
requested_by TEXT NOT NULL REFERENCES identities(id), -- the identity of the session that asked
author TEXT, -- the account bound to sign (Ğ1 address), when the page knew it
salt TEXT NOT NULL, -- 128 random bits, 32 hex digits
digest TEXT NOT NULL UNIQUE, -- H = blake2b-256 of the canonical ballot
remark_hash TEXT NOT NULL UNIQUE, -- 0x + blake2b-256 of `ld1:<H>`: what the indexer finds it by
account TEXT NOT NULL, -- the vote account at the time (Ğ1 address)
amount INTEGER NOT NULL, -- cents of Ğ1 asked
status TEXT NOT NULL, -- pending | confirmed | refused | expired
reason TEXT, -- refused: no_identity | not_member | transfer
voter_id TEXT REFERENCES identities(id), -- idty:<index> of the signer, once confirmed
signer TEXT, -- the account that signed (Ğ1 address)
block INTEGER, -- where the remark sits on the chain
event_index INTEGER,
event_id TEXT,
entered TEXT, -- JSON: what entered into force at the confirmation
created_at TEXT NOT NULL,
expires_at TEXT NOT NULL,
checked_at TEXT, -- last reading of the indexer
settled_at TEXT -- confirmed, refused or expired
);
CREATE INDEX ballots_pending ON ballots(status, created_at);
CREATE INDEX ballots_requested ON ballots(requested_by);
CREATE INDEX ballots_voter ON ballots(voter_id);
CREATE TABLE ballot_lines (
ballot_id TEXT NOT NULL REFERENCES ballots(id),
proposal_id TEXT NOT NULL REFERENCES proposals(id),
revision INTEGER NOT NULL, -- the revision voted: counted only while it is the proposal's
choice TEXT NOT NULL, -- for | against | none (withdraws)
PRIMARY KEY (ballot_id, proposal_id)
);
CREATE INDEX ballot_lines_proposal ON ballot_lines(proposal_id);
-- Where a vote comes from, and for a vote of the chain, where it sits on it.
ALTER TABLE votes ADD COLUMN source TEXT NOT NULL DEFAULT 'server'; -- server | chain
ALTER TABLE votes ADD COLUMN block INTEGER;
ALTER TABLE votes ADD COLUMN event_index INTEGER;
ALTER TABLE votes ADD COLUMN ballot_id TEXT;
+639
View File
@@ -0,0 +1,639 @@
//! The vote, grouped: the whole document, a section or an article in one
//! gesture (Yvv, 2026-10-30: « Je peux voter tout d'un coup, ou une section
//! entière, ou un article avec son titre, ou séparément »).
//!
//! - In a **managed group** (essai, Axiom…), the votes are written on the
//! server in one transaction, as before: the test group stays easy to try.
//! - Where the electorate is the **Ğ1 web of trust** (Monnaie libre), a vote is
//! one transaction on the chain, signed by the person's wallet (ADR 0008):
//! the server seals a **pending ballot** (`ld_engine::ballot`), the page has
//! it signed and sent, and the server **confirms** it by reading the indexer:
//! the remark, the transfer of the amount to the vote account in the same
//! transaction, the signer's identity and its membership — now. The vote
//! counts for the identity `idty:<index>` of the signer, created if need be;
//! the session that asked for the ballot sees it as « ton vote ».
//! - **The last one stands**, per proposal and per identity, in the order of
//! the chain: a ballot confirmed late never overrides a more recent one.
//!
//! A background task reads the indexer for pending ballots every 10 seconds
//! while there are some, lets them expire after 24 hours, and runs the
//! adoption pass after each confirmation.
use crate::AppState;
use crate::adoption::{self, Entered};
use crate::auth::{IdentityRow, User};
use crate::docs::{self, DocState};
use crate::error::{AppError, AppResult};
use crate::indexer::{self, Found, Reading};
use crate::util;
use axum::Json;
use axum::extract::{Path, State};
use ld_engine::address;
use ld_engine::ballot::{self, ChainPosition, Choice, Expected, Verdict};
use rand::RngCore;
use serde::Deserialize;
use serde_json::json;
use std::collections::{HashMap, HashSet};
use std::time::Duration;
use tokio::sync::Mutex;
use trustin_access::VoteWay;
/// Every article of a long document, with room to spare.
const MAX_LINES: usize = 200;
/// Pending ballots one session may hold at once.
const MAX_PENDING: i64 = 20;
/// A ballot not seen on the chain within this time is let go.
pub const EXPIRES_HOURS: i64 = 24;
/// The page asks often; the indexer is read at most this often per ballot.
const RECHECK_SECS: i64 = 3;
const WATCH_EVERY: Duration = Duration::from_secs(10);
/// Pending ballots read per pass of the background task.
const PER_PASS: i64 = 50;
/// One confirmation at a time: « the last one stands » reads the other ballots of
/// the identity, then writes; two at once could both read before either writes.
static CONFIRM: Mutex<()> = Mutex::const_new(());
#[derive(Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct LineBody {
proposal_id: String,
/// `for`, `against`, or null to withdraw.
choice: Option<String>,
}
#[derive(Deserialize)]
pub struct VotesBody {
lines: Vec<LineBody>,
}
#[derive(Deserialize)]
pub struct BallotBody {
lines: Vec<LineBody>,
/// The account that will sign, in any SS58 form, when the page knows it.
author: Option<String>,
}
/// A line checked against the document: a votable version, its revision now.
pub(crate) struct Cast {
pub proposal_id: String,
pub revision: i64,
pub choice: Choice,
}
fn parse_choice(raw: Option<&str>) -> AppResult<Choice> {
match raw {
None | Some("none") => Ok(Choice::None),
Some(c) => Choice::parse(c).ok_or(AppError::Invalid("choice")),
}
}
/// The lines of a vote, read against the document: at least one, each version
/// once, each one of this document and votable.
fn casts(st: &DocState, lines: &[LineBody]) -> AppResult<Vec<Cast>> {
if lines.is_empty() || lines.len() > MAX_LINES {
return Err(AppError::Invalid("lines"));
}
let mut seen = HashSet::new();
let mut out = Vec::with_capacity(lines.len());
for l in lines {
if !seen.insert(l.proposal_id.as_str()) {
return Err(AppError::Invalid("lines"));
}
let p = st.proposals.iter().find(|p| p.id == l.proposal_id).ok_or(AppError::Invalid("lines"))?;
if !matches!(p.status.as_str(), "open" | "in_force") {
return Err(AppError::Conflict("not_votable"));
}
out.push(Cast { proposal_id: p.id.clone(), revision: p.revision, choice: parse_choice(l.choice.as_deref())? });
}
Ok(out)
}
/// Writes votes on the server, all or none.
pub(crate) async fn write_server_votes(state: &AppState, identity: &str, casts: &[Cast]) -> AppResult<()> {
let mut tx = state.db.begin().await?;
let now = util::now();
for c in casts {
match c.choice {
Choice::None => {
sqlx::query("DELETE FROM votes WHERE proposal_id = ? AND identity_id = ?")
.bind(&c.proposal_id)
.bind(identity)
.execute(&mut *tx)
.await?;
}
choice => {
sqlx::query(
"INSERT INTO votes (proposal_id, identity_id, choice, revision, cast_at, source) VALUES (?, ?, ?, ?, ?, 'server')
ON CONFLICT(proposal_id, identity_id) DO UPDATE SET choice = excluded.choice,
revision = excluded.revision, cast_at = excluded.cast_at, source = 'server',
block = NULL, event_index = NULL, ballot_id = NULL",
)
.bind(&c.proposal_id)
.bind(identity)
.bind(choice.as_str())
.bind(c.revision)
.bind(&now)
.execute(&mut *tx)
.await?;
}
}
}
tx.commit().await?;
Ok(())
}
/// `POST /api/docs/{id}/votes` — a grouped vote in a managed group: one write.
pub async fn vote_many(
State(state): State<AppState>,
User(user): User,
Path(doc_id): Path<String>,
Json(b): Json<VotesBody>,
) -> AppResult<Json<serde_json::Value>> {
let st = docs::load_state(&state, &doc_id).await?;
docs::require_open(&state, &user, &st.group).await?;
if trustin_access::vote_way(&st.group.to_group()) == VoteWay::Chain {
return Err(AppError::Conflict("chain_only"));
}
if !docs::may_vote(&state, &user, &st.group).await? {
return Err(AppError::Forbidden);
}
let casts = casts(&st, &b.lines)?;
write_server_votes(&state, &user.id, &casts).await?;
let entered = adoption::run(&state, &st.doc.id).await?;
Ok(Json(json!({ "count": casts.len(), "entered": entered })))
}
// ---------------------------------------------------------------------------
// Ballots of the chain
// ---------------------------------------------------------------------------
#[derive(Clone, Debug, sqlx::FromRow)]
pub struct BallotRow {
pub id: String,
pub doc_id: String,
pub requested_by: String,
pub author: Option<String>,
pub digest: String,
pub remark_hash: String,
pub account: String,
pub amount: i64,
pub status: String,
pub reason: Option<String>,
pub voter_id: Option<String>,
pub block: Option<i64>,
pub entered: Option<String>,
pub created_at: String,
pub expires_at: String,
pub checked_at: Option<String>,
}
const BALLOT_SELECT: &str = "SELECT id, doc_id, requested_by, author, digest, remark_hash, account, amount, status,
reason, voter_id, block, entered, created_at, expires_at, checked_at FROM ballots";
impl BallotRow {
pub fn comment(&self) -> String {
format!("{}{}", ballot::PREFIX, self.digest)
}
}
fn salt() -> String {
let mut bytes = [0u8; 16];
rand::rng().fill_bytes(&mut bytes);
hex::encode(bytes)
}
/// `POST /api/docs/{id}/ballots` — seal a ballot for the wallet to sign.
pub async fn create(
State(state): State<AppState>,
User(user): User,
Path(doc_id): Path<String>,
Json(b): Json<BallotBody>,
) -> AppResult<Json<serde_json::Value>> {
let st = docs::load_state(&state, &doc_id).await?;
docs::require_open(&state, &user, &st.group).await?;
let group = st.group.to_group();
if trustin_access::vote_way(&group) == VoteWay::Server {
return Err(AppError::Conflict("server_vote"));
}
let granted = docs::granted(&state, &group.slug, &user.id).await?;
if !trustin_access::asks_ballot(&user.to_identity(), &group, granted) {
return Err(AppError::Forbidden);
}
let chain = state.cfg.vote.as_ref().ok_or(AppError::Conflict("chain_vote_off"))?;
let casts = casts(&st, &b.lines)?;
let author = match b.author.as_deref().map(str::trim).filter(|a| !a.is_empty()) {
Some(a) => Some(address::to_g1(a).ok_or(AppError::Invalid("author"))?),
None => None,
};
let pending: i64 = sqlx::query_scalar(
"SELECT COUNT(*) FROM ballots WHERE requested_by = ? AND status = 'pending' AND expires_at > ?",
)
.bind(&user.id)
.bind(util::now())
.fetch_one(&state.db)
.await?;
if pending >= MAX_PENDING {
return Err(AppError::Conflict("too_many_ballots"));
}
let lines: Vec<ballot::Line> = casts
.iter()
.map(|c| ballot::Line {
proposal_id: c.proposal_id.clone(),
revision: u32::try_from(c.revision).unwrap_or(u32::MAX),
choice: c.choice,
})
.collect();
let salt = salt();
let sealed = ballot::seal(&st.doc.id, author.as_deref(), &lines, &salt).map_err(|_| AppError::Invalid("lines"))?;
let id = util::id();
let now = util::now();
let expires = util::in_seconds(EXPIRES_HOURS * 3600);
let mut tx = state.db.begin().await?;
sqlx::query(
"INSERT INTO ballots (id, doc_id, requested_by, author, salt, digest, remark_hash, account, amount, status,
created_at, expires_at)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, 'pending', ?, ?)",
)
.bind(&id)
.bind(&st.doc.id)
.bind(&user.id)
.bind(&author)
.bind(&salt)
.bind(&sealed.digest)
.bind(&sealed.remark_hash)
.bind(&chain.account)
.bind(i64::try_from(chain.amount).unwrap_or(i64::MAX))
.bind(&now)
.bind(&expires)
.execute(&mut *tx)
.await?;
for c in &casts {
sqlx::query("INSERT INTO ballot_lines (ballot_id, proposal_id, revision, choice) VALUES (?, ?, ?, ?)")
.bind(&id)
.bind(&c.proposal_id)
.bind(c.revision)
.bind(c.choice.as_str())
.execute(&mut *tx)
.await?;
}
tx.commit().await?;
Ok(Json(json!({ "ballot": {
"id": id, "status": "pending", "comment": sealed.comment, "account": chain.account,
"amount": chain.amount, "lines": casts.len(), "author": author, "expiresAt": expires,
} })))
}
async fn load(state: &AppState, id: &str) -> AppResult<BallotRow> {
sqlx::query_as::<_, BallotRow>(&format!("{BALLOT_SELECT} WHERE id = ?"))
.bind(id)
.fetch_optional(&state.db)
.await?
.ok_or(AppError::NotFound)
}
/// `GET /api/ballots/{id}` — where a ballot stands, for the session that asked
/// for it. A pending ballot is looked for on the indexer at most every few seconds.
pub async fn status(
State(state): State<AppState>,
User(user): User,
Path(id): Path<String>,
) -> AppResult<Json<serde_json::Value>> {
let mut row = load(&state, &id).await?;
if row.requested_by != user.id {
return Err(AppError::NotFound);
}
if row.status == "pending" && row.checked_at.as_ref().is_none_or(|c| *c < util::in_seconds(-RECHECK_SECS)) {
check(&state, &row).await?;
row = load(&state, &id).await?;
}
Ok(Json(json!({ "ballot": view(&state, &row).await? })))
}
async fn view(state: &AppState, row: &BallotRow) -> AppResult<serde_json::Value> {
let voter: Option<String> = match &row.voter_id {
Some(v) => {
sqlx::query_scalar("SELECT display_name FROM identities WHERE id = ?")
.bind(v)
.fetch_optional(&state.db)
.await?
}
None => None,
};
let entered: serde_json::Value =
row.entered.as_deref().and_then(|e| serde_json::from_str(e).ok()).unwrap_or_else(|| json!([]));
Ok(json!({
"id": row.id, "status": row.status, "reason": row.reason, "comment": row.comment(),
"account": row.account, "amount": row.amount, "author": row.author, "block": row.block,
"voter": voter, "entered": entered, "expiresAt": row.expires_at,
}))
}
/// Reads the indexer for one pending ballot and settles it if the chain has spoken.
pub async fn check(state: &AppState, row: &BallotRow) -> AppResult<()> {
let now = util::now();
if row.expires_at <= now {
expire(state).await?;
return Ok(());
}
let reading = indexer::comments(state, &row.remark_hash).await;
sqlx::query("UPDATE ballots SET checked_at = ? WHERE id = ?").bind(&now).bind(&row.id).execute(&state.db).await?;
let Reading::Found(list) = reading else {
tracing::debug!(ballot = %row.id, "no indexer answered: the ballot stays pending");
return Ok(());
};
let comment = row.comment();
let list: Vec<Found> = list.into_iter().filter(|f| f.remark == comment).collect();
let (Some((_, account)), Some(amount)) = (address::decode(&row.account), u64::try_from(row.amount).ok()) else {
return Err(anyhow::anyhow!("ballot {} has an unreadable account or amount", row.id).into());
};
let expected =
Expected { account, amount, author: row.author.as_deref().and_then(address::decode).map(|(_, k)| k) };
let comments: Vec<ballot::Comment> = list.iter().map(|f| f.comment.clone()).collect();
let Some((position, verdict)) = ballot::judge(&comments, &expected) else { return Ok(()) };
let Some(found) = list.iter().find(|f| f.comment.position == position) else { return Ok(()) };
match verdict {
// The right to vote is checked now, at confirmation, by trustinWallet's rule.
Verdict::Paid { identity } if trustin_access::chain_vote_counts(identity.member) => {
confirm(state, row, found, identity.index).await
}
Verdict::Paid { .. } => refuse(state, row, found, "not_member").await,
other => refuse(state, row, found, other.reason().unwrap_or("refused")).await,
}
}
async fn refuse(state: &AppState, row: &BallotRow, found: &Found, reason: &str) -> AppResult<()> {
let done = sqlx::query(
"UPDATE ballots SET status = 'refused', reason = ?, signer = ?, block = ?, event_index = ?, event_id = ?,
settled_at = ? WHERE id = ? AND status = 'pending'",
)
.bind(reason)
.bind(&found.signer)
.bind(i64::from(found.comment.position.block))
.bind(i64::from(found.comment.position.event))
.bind(&found.event_id)
.bind(util::now())
.bind(&row.id)
.execute(&state.db)
.await?;
if done.rows_affected() == 1 {
tracing::info!(ballot = %row.id, reason, "ballot refused");
}
Ok(())
}
/// The ballot counts: written for the identity of the signer, line by line,
/// where no more recent ballot of that identity decided already.
async fn confirm(state: &AppState, row: &BallotRow, found: &Found, index: u32) -> AppResult<()> {
let voter = format!("idty:{index}");
let position = found.comment.position;
let now = util::now();
{
let _one = CONFIRM.lock().await;
let mut tx = state.db.begin().await?;
// The identity of the signer, as the chain has it now: a member (just
// checked), with its name and its account. Undone with the rest if the
// ballot was settled in the meantime.
let admin = state.cfg.admin_g1_ids.contains(&voter);
sqlx::query(
"INSERT INTO identities (id, kind, display_name, address, g1_member, admin, created_at)
VALUES (?, 'g1', ?, ?, 1, ?, ?)
ON CONFLICT(id) DO UPDATE SET display_name = excluded.display_name, address = excluded.address,
g1_member = 1, admin = MAX(identities.admin, excluded.admin)",
)
.bind(&voter)
.bind(found.name.clone().unwrap_or_else(|| voter.clone()))
.bind(&found.signer)
.bind(admin)
.bind(&now)
.execute(&mut *tx)
.await?;
let won = sqlx::query(
"UPDATE ballots SET status = 'confirmed', voter_id = ?, signer = ?, block = ?, event_index = ?,
event_id = ?, settled_at = ? WHERE id = ? AND status = 'pending'",
)
.bind(&voter)
.bind(&found.signer)
.bind(i64::from(position.block))
.bind(i64::from(position.event))
.bind(&found.event_id)
.bind(&now)
.bind(&row.id)
.execute(&mut *tx)
.await?;
if won.rows_affected() != 1 {
// Settled in the meantime: confirming twice changes nothing.
tx.rollback().await?;
return Ok(());
}
let lines: Vec<(String, i64, String)> =
sqlx::query_as("SELECT proposal_id, revision, choice FROM ballot_lines WHERE ballot_id = ?")
.bind(&row.id)
.fetch_all(&mut *tx)
.await?;
for (proposal_id, revision, choice) in lines {
let others: Vec<(i64, i64)> = sqlx::query_as(
"SELECT b.block, b.event_index FROM ballots b JOIN ballot_lines l ON l.ballot_id = b.id
WHERE b.status = 'confirmed' AND b.voter_id = ? AND l.proposal_id = ? AND b.id <> ?
AND b.block IS NOT NULL AND b.event_index IS NOT NULL",
)
.bind(&voter)
.bind(&proposal_id)
.bind(&row.id)
.fetch_all(&mut *tx)
.await?;
let others: Vec<ChainPosition> = others
.into_iter()
.filter_map(|(b, e)| {
Some(ChainPosition { block: u32::try_from(b).ok()?, event: u32::try_from(e).ok()? })
})
.collect();
if !ballot::decides(position, &others) {
continue;
}
match Choice::parse(&choice) {
Some(Choice::None) => {
sqlx::query("DELETE FROM votes WHERE proposal_id = ? AND identity_id = ?")
.bind(&proposal_id)
.bind(&voter)
.execute(&mut *tx)
.await?;
}
Some(c) => {
sqlx::query(
"INSERT INTO votes (proposal_id, identity_id, choice, revision, cast_at, source, block, event_index, ballot_id)
VALUES (?, ?, ?, ?, ?, 'chain', ?, ?, ?)
ON CONFLICT(proposal_id, identity_id) DO UPDATE SET choice = excluded.choice,
revision = excluded.revision, cast_at = excluded.cast_at, source = 'chain',
block = excluded.block, event_index = excluded.event_index, ballot_id = excluded.ballot_id",
)
.bind(&proposal_id)
.bind(&voter)
.bind(c.as_str())
.bind(revision)
.bind(&now)
.bind(i64::from(position.block))
.bind(i64::from(position.event))
.bind(&row.id)
.execute(&mut *tx)
.await?;
}
None => tracing::warn!(ballot = %row.id, %choice, "unknown choice in a ballot line"),
}
}
tx.commit().await?;
}
tracing::info!(ballot = %row.id, block = position.block, event = position.event, "ballot confirmed");
let entered: Vec<Entered> = adoption::run(state, &row.doc_id).await?;
sqlx::query("UPDATE ballots SET entered = ? WHERE id = ?")
.bind(serde_json::to_string(&entered).unwrap_or_else(|_| "[]".into()))
.bind(&row.id)
.execute(&state.db)
.await?;
Ok(())
}
/// Lets go of the pending ballots not seen on the chain in time.
pub async fn expire(state: &AppState) -> AppResult<u64> {
let now = util::now();
let done = sqlx::query(
"UPDATE ballots SET status = 'expired', settled_at = ? WHERE status = 'pending' AND expires_at <= ?",
)
.bind(&now)
.bind(&now)
.execute(&state.db)
.await?;
Ok(done.rows_affected())
}
/// One pass of the background task: expire, then read the indexer for each
/// pending ballot, oldest first. Returns how many were read.
pub async fn sweep(state: &AppState) -> AppResult<usize> {
expire(state).await?;
let rows = sqlx::query_as::<_, BallotRow>(&format!(
"{BALLOT_SELECT} WHERE status = 'pending' ORDER BY created_at, rowid LIMIT {PER_PASS}"
))
.fetch_all(&state.db)
.await?;
for row in &rows {
if let Err(e) = check(state, row).await {
tracing::warn!(ballot = %row.id, error = %e, "ballot check");
}
}
Ok(rows.len())
}
pub async fn watcher(state: AppState) {
let mut tick = tokio::time::interval(WATCH_EVERY);
loop {
tick.tick().await;
if let Err(e) = sweep(&state).await {
tracing::warn!(error = %e, "ballots");
}
}
}
// ---------------------------------------------------------------------------
// « Ton vote »
// ---------------------------------------------------------------------------
/// A vote of the person, on one version: hers, or cast by a ballot she asked for.
#[derive(Clone, Debug, sqlx::FromRow)]
pub struct MyVote {
pub proposal_id: String,
pub choice: String,
pub revision: i64,
pub source: String,
}
/// The votes `me` sees as « ton vote » in a document: those of its own identity,
/// and those a ballot it asked for cast on the chain (for the identity of the
/// wallet that signed). Only those: a session never sees what another session of
/// the same Ğ1 identity voted. The more recent on the chain comes last and wins.
pub async fn my_votes(state: &AppState, me: &str, doc_id: &str) -> AppResult<HashMap<String, MyVote>> {
let rows = sqlx::query_as::<_, MyVote>(
"SELECT v.proposal_id, v.choice, v.revision, v.source FROM votes v
JOIN proposals p ON p.id = v.proposal_id JOIN units u ON u.id = p.unit_id
WHERE u.doc_id = ?
AND (v.identity_id = ? OR v.ballot_id IN (SELECT id FROM ballots WHERE requested_by = ? AND status = 'confirmed'))
ORDER BY IFNULL(v.block, 0), IFNULL(v.event_index, 0), v.cast_at",
)
.bind(doc_id)
.bind(me)
.bind(me)
.fetch_all(&state.db)
.await?;
Ok(rows.into_iter().map(|v| (v.proposal_id.clone(), v)).collect())
}
/// What a ballot of the person, not yet counted, says of one version: pending on
/// the chain, or refused, or let go.
#[derive(Clone, Debug, serde::Serialize)]
#[serde(rename_all = "camelCase")]
pub struct MyBallot {
pub id: String,
pub status: String,
pub reason: Option<String>,
pub choice: String,
}
/// Per version: the most recent pending ballot of `me`; else its most recent
/// ballot if that one was refused or let go (a confirmed one shows as its vote).
pub async fn my_ballots(state: &AppState, me: &str, doc_id: &str) -> AppResult<HashMap<String, MyBallot>> {
#[derive(sqlx::FromRow)]
struct Row {
proposal_id: String,
id: String,
status: String,
reason: Option<String>,
choice: String,
}
let rows = sqlx::query_as::<_, Row>(
"SELECT l.proposal_id, b.id, b.status, b.reason, l.choice FROM ballots b
JOIN ballot_lines l ON l.ballot_id = b.id
WHERE b.doc_id = ? AND b.requested_by = ? ORDER BY b.created_at, b.rowid",
)
.bind(doc_id)
.bind(me)
.fetch_all(&state.db)
.await?;
let mut latest: HashMap<String, MyBallot> = HashMap::new();
let mut pending: HashMap<String, MyBallot> = HashMap::new();
for r in rows {
let b = MyBallot { id: r.id, status: r.status, reason: r.reason, choice: r.choice };
if b.status == "pending" {
pending.insert(r.proposal_id.clone(), b.clone());
}
latest.insert(r.proposal_id, b);
}
latest.retain(|_, b| b.status != "confirmed");
latest.extend(pending);
Ok(latest)
}
/// How this session votes in a group, and whether it may now.
pub async fn access(state: &AppState, user: &IdentityRow, group: &docs::GroupRow) -> AppResult<serde_json::Value> {
let g = group.to_group();
let granted = docs::granted(state, &g.slug, &user.id).await?;
let id = user.to_identity();
let way = trustin_access::vote_way(&g);
let (open, reason) = match way {
VoteWay::Server => {
let open = trustin_access::votes_in(&id, &g, granted);
(open, (!open).then_some("members_only"))
}
VoteWay::Chain => match (&state.cfg.vote, trustin_access::asks_ballot(&id, &g, granted)) {
(None, _) => (false, Some("chain_off")),
(Some(_), false) => (false, Some("members_only")),
(Some(_), true) => (true, None),
},
};
let chain = match (way, &state.cfg.vote) {
(VoteWay::Chain, Some(c)) => json!({
"account": c.account, "amount": c.amount, "nodes": c.nodes,
"test": c.test_node.is_some(), "testNode": c.test_node, "expiresHours": EXPIRES_HOURS,
}),
_ => serde_json::Value::Null,
};
Ok(json!({ "way": way, "open": open, "reason": reason, "chain": chain }))
}
+50 -1
View File
@@ -21,6 +21,39 @@ pub struct Publish {
pub author_email: String,
}
/// The vote on the Ğ1 chain (ADR 0008): where the 0.1 Ğ1 of each vote go, how
/// much, and the nodes the page sends the transaction to. Without
/// `LD_VOTE_ACCOUNT`, voting through the chain is off, and the interface says so.
#[derive(Clone, Debug)]
pub struct VoteChain {
/// The vote account, as a Ğ1 address (`g1…`), whatever form it was given in.
pub account: String,
pub account_key: [u8; 32],
/// Cents of Ğ1 per vote (`LD_VOTE_AMOUNT`, 10 = 0.1 Ğ1 by default).
pub amount: u64,
/// WebSocket endpoints of Ğ1 nodes, for the page (`LD_VOTE_NODES`).
pub nodes: Vec<String>,
/// Tests only, with the dev login: the page sends to this fake node instead
/// of building the transaction with polkadot-api (`LD_VOTE_TEST_NODE`).
pub test_node: Option<String>,
}
impl VoteChain {
pub const DEFAULT_AMOUNT: u64 = 10;
/// From the account in any SS58 form; `None` if it is not an address.
pub fn new(account: &str, amount: u64, nodes: Vec<String>, test_node: Option<String>) -> Option<Self> {
let (_, key) = ld_engine::address::decode(account.trim())?;
Some(Self {
account: ld_engine::address::encode(ld_engine::address::G1_PREFIX, &key),
account_key: key,
amount: amount.max(1),
nodes,
test_node,
})
}
}
#[derive(Clone, Debug)]
pub struct Config {
pub bind: String,
@@ -38,6 +71,8 @@ pub struct Config {
pub indexers: Vec<String>,
pub wot_fallback: u32,
pub build: String,
/// The vote on the chain; `None` = off (no `LD_VOTE_ACCOUNT`).
pub vote: Option<VoteChain>,
}
fn var(name: &str) -> Option<String> {
@@ -76,6 +111,18 @@ impl Config {
author_name: var("LD_PUBLISH_AUTHOR").unwrap_or_else(|| "libreDecision".into()),
author_email: var("LD_PUBLISH_EMAIL").unwrap_or_else(|| "decision@librodrome.org".into()),
});
let dev_login = flag("LD_DEV_LOGIN");
let vote = var("LD_VOTE_ACCOUNT").and_then(|account| {
let amount = var("LD_VOTE_AMOUNT").and_then(|v| v.parse().ok()).unwrap_or(VoteChain::DEFAULT_AMOUNT);
let nodes = list("LD_VOTE_NODES", &["wss://rpc.duniter.org", "wss://g1.axiom-team.fr/ws"]);
// A fake node for the page, never outside the local dev login.
let test_node = var("LD_VOTE_TEST_NODE").filter(|_| dev_login);
let chain = VoteChain::new(&account, amount, nodes, test_node);
if chain.is_none() {
tracing::error!("LD_VOTE_ACCOUNT is not a Ğ1 address: the vote through the chain stays off");
}
chain
});
Self {
bind: var("LD_BIND").unwrap_or_else(|| "0.0.0.0:8002".into()),
web_dir: PathBuf::from(var("LD_WEB_DIR").unwrap_or_else(|| "web/build".into())),
@@ -86,7 +133,7 @@ impl Config {
.to_owned(),
connect,
admin_g1_ids: list("LD_ADMIN_G1_IDS", &[]),
dev_login: flag("LD_DEV_LOGIN"),
dev_login,
bootstrap_admin: flag("LD_BOOTSTRAP_ADMIN"),
publish,
rpc_endpoints: list(
@@ -104,6 +151,7 @@ impl Config {
),
wot_fallback: var("LD_WOT_FALLBACK").and_then(|v| v.parse().ok()).unwrap_or(5745),
build: var("LD_BUILD").unwrap_or_else(|| "dev".into()),
vote,
data_dir,
}
}
@@ -134,6 +182,7 @@ impl Config {
indexers: vec![],
wot_fallback: 5745,
build: "test".into(),
vote: None,
}
}
}
+14 -22
View File
@@ -5,6 +5,7 @@
use crate::AppState;
use crate::auth::{IdentityRow, User};
use crate::ballots::{self, MyBallot, MyVote};
use crate::error::{AppError, AppResult};
use crate::participation;
use crate::util::{self, LangMap};
@@ -515,21 +516,15 @@ pub async fn translations_for(
Ok(out)
}
#[derive(sqlx::FromRow)]
struct MyVote {
proposal_id: String,
choice: String,
revision: i64,
}
fn proposal_json(
st: &DocState,
unit: &UnitRow,
p: &ProposalRow,
mine: Option<&MyVote>,
mine: (Option<&MyVote>, Option<&MyBallot>),
translations: Option<&BTreeMap<String, serde_json::Value>>,
me: &str,
) -> serde_json::Value {
let (mine, ballot) = mine;
let eval = st.evaluation(unit, p);
let (kind, target) = match p.kind() {
ProposalKind::Text => ("text", None),
@@ -554,6 +549,8 @@ fn proposal_json(
"signals": st.signal_counts.get(&p.id).copied().unwrap_or(0),
"myVote": mine.map(|v| v.choice.as_str()),
"myVoteStale": mine.is_some_and(|v| v.revision != p.revision),
"myVoteChain": mine.is_some_and(|v| v.source == "chain"),
"myBallot": ballot,
"translations": translations.cloned().unwrap_or_default(),
})
}
@@ -571,17 +568,8 @@ pub async fn document(
let visible: Vec<&ProposalRow> =
st.proposals.iter().filter(|p| p.status != "draft" || p.author_id.as_deref() == Some(&user.id)).collect();
let translations = translations_for(&state, &visible).await?;
let mine: HashMap<String, MyVote> = sqlx::query_as::<_, MyVote>(
"SELECT v.proposal_id, v.choice, v.revision FROM votes v JOIN proposals p ON p.id = v.proposal_id
JOIN units u ON u.id = p.unit_id WHERE u.doc_id = ? AND v.identity_id = ?",
)
.bind(&id)
.bind(&user.id)
.fetch_all(&state.db)
.await?
.into_iter()
.map(|v| (v.proposal_id.clone(), v))
.collect();
let mine = ballots::my_votes(&state, &user.id, &id).await?;
let pending = ballots::my_ballots(&state, &user.id, &id).await?;
let changes = inertia_changes(&state, &id).await?;
let units: Vec<_> = st
@@ -591,7 +579,10 @@ pub async fn document(
let of_unit: Vec<_> = visible
.iter()
.filter(|p| p.unit_id == u.id)
.map(|p| proposal_json(&st, u, p, mine.get(&p.id), translations.get(&p.id), &user.id))
.map(|p| {
let of = (mine.get(&p.id), pending.get(&p.id));
proposal_json(&st, u, p, of, translations.get(&p.id), &user.id)
})
.collect();
let in_force = u
.in_force_id
@@ -607,9 +598,10 @@ pub async fn document(
.collect();
let publication = publication_status(&state, &id).await?;
let can_vote = may_vote(&state, &user, &st.group).await?;
let vote = ballots::access(&state, &user, &st.group).await?;
Ok(Json(json!({
"canVote": can_vote,
"canVote": vote["open"],
"vote": vote,
"doc": {
"id": st.doc.id, "slug": st.doc.slug, "groupSlug": st.group.slug, "groupName": st.group.name,
"title": util::parse_lang(&st.doc.title), "summary": util::parse_lang(&st.doc.summary),
+223
View File
@@ -0,0 +1,223 @@
//! Reading the Ğ1 indexer (duniter-squid, GraphQL): the comments that carry a
//! given remark, with their author, the author's identity and the transfers of
//! the same transaction.
//!
//! A comment is found by `hash`, the blake2b-256 of the remark's bytes that the
//! chain publishes in its `System.Remarked` event. The indexers of
//! `LD_INDEXERS` are asked in turn: one that does not answer, or answers with
//! errors, is skipped; one that answers « nothing » may be behind, so the next
//! is asked too. Nobody answering is not a « no »: the ballot stays pending.
use crate::AppState;
use ld_engine::address;
use ld_engine::ballot::{ChainIdentity, ChainPosition, Comment, Transfer};
use serde::Deserialize;
use serde_json::json;
use std::time::Duration;
/// Validated on the indexer of the Ğ1 chain (indexer.duniter.org), 2026-10-30.
pub const BALLOT_QUERY: &str = "query Ballot($hash: String!) { txComments(condition: {hash: $hash}, \
orderBy: BLOCK_NUMBER_ASC, first: 20) { nodes { id blockNumber remark author { id identity { index name \
isMember } } transfersByCommentId { nodes { fromId toId amount } } } } }";
const TIMEOUT: Duration = Duration::from_secs(6);
#[derive(Deserialize)]
struct Answer {
data: Option<Data>,
#[serde(default)]
errors: Option<serde_json::Value>,
}
#[derive(Deserialize)]
#[serde(rename_all = "camelCase")]
struct Data {
tx_comments: Nodes<Node>,
}
#[derive(Deserialize)]
struct Nodes<T> {
nodes: Vec<T>,
}
#[derive(Deserialize)]
#[serde(rename_all = "camelCase")]
struct Node {
id: String,
block_number: u32,
remark: String,
author: Author,
transfers_by_comment_id: Nodes<TransferNode>,
}
#[derive(Deserialize)]
struct Author {
id: String,
identity: Option<IdentityNode>,
}
#[derive(Deserialize)]
#[serde(rename_all = "camelCase")]
struct IdentityNode {
index: u32,
name: String,
is_member: bool,
}
#[derive(Deserialize)]
#[serde(rename_all = "camelCase")]
struct TransferNode {
from_id: String,
to_id: String,
/// A big integer, served as a string.
amount: serde_json::Value,
}
/// A comment of the chain, as the engine judges it, with what the server keeps.
#[derive(Clone, Debug)]
pub struct Found {
pub comment: Comment,
pub remark: String,
pub event_id: String,
/// The account that signed, as a Ğ1 address.
pub signer: String,
/// The name of the signer's identity, if any.
pub name: Option<String>,
}
#[derive(Debug)]
pub enum Reading {
/// No indexer answered.
Silent,
/// The comments found (possibly none).
Found(Vec<Found>),
}
fn amount(v: &serde_json::Value) -> Option<u64> {
match v {
serde_json::Value::String(s) => s.parse().ok(),
serde_json::Value::Number(n) => n.as_u64(),
_ => None,
}
}
fn found(node: Node) -> Option<Found> {
let position = ChainPosition::from_event_id(&node.id)?;
if position.block != node.block_number {
return None;
}
let (_, author) = address::decode(&node.author.id)?;
let transfers = node
.transfers_by_comment_id
.nodes
.iter()
.map(|t| {
Some(Transfer {
from: address::decode(&t.from_id)?.1,
to: address::decode(&t.to_id)?.1,
amount: amount(&t.amount)?,
})
})
.collect::<Option<Vec<_>>>()?;
Some(Found {
comment: Comment {
position,
author,
identity: node.author.identity.as_ref().map(|i| ChainIdentity { index: i.index, member: i.is_member }),
transfers,
},
remark: node.remark,
event_id: node.id,
signer: address::encode(address::G1_PREFIX, &author),
name: node.author.identity.map(|i| i.name),
})
}
/// The comments of one answer of the indexer; `None` if it is not one.
pub fn parse(body: &serde_json::Value) -> Option<Vec<Found>> {
let answer: Answer = serde_json::from_value(body.clone()).ok()?;
if answer.errors.as_ref().is_some_and(|e| !e.is_null()) {
return None;
}
let nodes = answer.data?.tx_comments.nodes;
let count = nodes.len();
let out: Vec<Found> = nodes.into_iter().filter_map(found).collect();
if out.len() != count {
tracing::warn!(read = out.len(), served = count, "indexer: comments that do not read were left aside");
}
Some(out)
}
/// The comments carrying the remark of hash `remark_hash` (`0x…`).
pub async fn comments(state: &AppState, remark_hash: &str) -> Reading {
let body = json!({ "query": BALLOT_QUERY, "variables": { "hash": remark_hash } });
let mut answered = false;
for url in &state.cfg.indexers {
let res = state.http.post(url).timeout(TIMEOUT).json(&body).send().await;
let value: Option<serde_json::Value> = match res {
Ok(r) if r.status().is_success() => r.json().await.ok(),
Ok(r) => {
tracing::debug!(%url, status = %r.status(), "indexer");
None
}
Err(e) => {
tracing::debug!(%url, error = %e, "indexer");
None
}
};
match value.as_ref().and_then(parse) {
Some(list) if !list.is_empty() => return Reading::Found(list),
Some(_) => answered = true,
None => {}
}
}
if answered { Reading::Found(Vec::new()) } else { Reading::Silent }
}
#[cfg(test)]
mod tests {
use super::*;
/// A real answer of the Ğ1 indexer (2026-10-30), for the remark « Merci ».
const REAL: &str = include_str!("../tests/fixtures/indexer-merci.json");
#[test]
fn a_real_answer_reads() {
let body: serde_json::Value = serde_json::from_str(REAL).expect("json");
let list = parse(&body).expect("an answer");
assert_eq!(list.len(), 4);
let first = &list[0];
assert_eq!(first.comment.position, ChainPosition { block: 196_944, event: 3 });
assert_eq!(first.remark, "Merci ");
assert_eq!(first.signer, "g1NrqKgJW9FoFabaYW82zhS9tDWdQcL1DGpivaJdj7GW9M6iv");
assert_eq!(first.name.as_deref(), Some("SIMONEANTONELLI"));
assert_eq!(first.comment.identity, Some(ChainIdentity { index: 1304, member: true }));
assert_eq!(first.comment.transfers.len(), 1);
assert_eq!(first.comment.transfers[0].amount, 30_000);
assert_eq!(first.comment.transfers[0].from, first.comment.author);
// An account without identity.
assert_eq!(list[2].comment.identity, None);
assert_eq!(list[2].name, None);
}
#[test]
fn errors_or_a_strange_shape_are_no_answer() {
assert!(parse(&json!({ "errors": [{ "message": "boom" }] })).is_none());
assert!(parse(&json!({ "data": null })).is_none());
assert!(parse(&json!({ "nope": 1 })).is_none());
assert_eq!(parse(&json!({ "data": { "txComments": { "nodes": [] } } })).map(|l| l.len()), Some(0));
}
#[test]
fn a_node_that_does_not_read_is_left_aside() {
let body = json!({ "data": { "txComments": { "nodes": [
{ "id": "0000000010-abcde-000002", "blockNumber": 11, "remark": "x",
"author": { "id": "g1NrqKgJW9FoFabaYW82zhS9tDWdQcL1DGpivaJdj7GW9M6iv", "identity": null },
"transfersByCommentId": { "nodes": [] } },
{ "id": "0000000010-abcde-000002", "blockNumber": 10, "remark": "x",
"author": { "id": "not an address", "identity": null },
"transfersByCommentId": { "nodes": [] } },
] } } });
assert_eq!(parse(&body).map(|l| l.len()), Some(0), "block and id disagree; a bad address");
}
}
+9 -2
View File
@@ -1,16 +1,19 @@
//! libreDecision server.
//!
//! One binary serves the JSON API under `/api`, the compiled Svelte interface,
//! and runs two background workers: the electorate refresher (size of the Ğ1
//! web of trust) and the publisher (adopted texts pushed to the git repository
//! and runs four background workers: the electorate refresher (size of the Ğ1
//! web of trust), the adoption sweeper, the ballot watcher (votes signed on the
//! Ğ1 chain, confirmed on the indexer) and the publisher (adopted texts pushed to the git repository
//! that client applications read).
pub mod access;
pub mod adoption;
pub mod auth;
pub mod ballots;
pub mod config;
pub mod docs;
pub mod error;
pub mod indexer;
pub mod inertia_amend;
pub mod overview;
pub mod participation;
@@ -116,6 +119,9 @@ pub fn api_router() -> Router<AppState> {
.route("/docs/{id}/preview", get(docs::preview))
.route("/docs/{id}/assist", post(proposals::assist))
.route("/docs/{id}/proposals", post(proposals::create))
.route("/docs/{id}/votes", post(ballots::vote_many))
.route("/docs/{id}/ballots", post(ballots::create))
.route("/ballots/{id}", get(ballots::status))
.route("/proposals/{id}", get(proposals::detail).patch(proposals::edit))
.route("/proposals/{id}/submit", post(proposals::submit))
.route("/proposals/{id}/withdraw", post(proposals::withdraw))
@@ -211,6 +217,7 @@ pub async fn run() -> anyhow::Result<()> {
tokio::spawn(wot::refresher(state.clone()));
tokio::spawn(publish::worker(state.clone()));
tokio::spawn(adoption::sweeper(state.clone()));
tokio::spawn(ballots::watcher(state.clone()));
let listener = tokio::net::TcpListener::bind(&state.cfg.bind).await?;
tracing::info!(bind = %state.cfg.bind, build = %state.cfg.build, "libreDecision listening");
+5 -3
View File
@@ -109,7 +109,7 @@ pub async fn decisions(
) -> AppResult<Json<Value>> {
let group = docs::load_group(&state, &slug).await?;
docs::require_open(&state, &user, &group).await?;
let can_vote = docs::may_vote(&state, &user, &group).await?;
let can_vote = crate::ballots::access(&state, &user, &group).await?["open"].as_bool().unwrap_or(false);
#[derive(sqlx::FromRow)]
struct DocRow {
id: String,
@@ -149,9 +149,11 @@ async fn modules(state: &AppState, doc_id: &str, me: &str) -> AppResult<Vec<Modu
IFNULL(p.id = u.in_force_id, 0) AS in_force,
(p.entered_at IS NOT NULL OR p.confirmed_at IS NOT NULL OR p.origin <> 'seed') AS adopted,
EXISTS (SELECT 1 FROM votes v
WHERE v.proposal_id = p.id AND v.identity_id = ? AND v.revision = p.revision) AS voted
WHERE v.proposal_id = p.id AND v.revision = p.revision
AND (v.identity_id = ?1 OR v.ballot_id IN
(SELECT b.id FROM ballots b WHERE b.requested_by = ?1 AND b.status = 'confirmed'))) AS voted
FROM units u JOIN proposals p ON p.unit_id = u.id
WHERE u.doc_id = ? AND p.kind <> 'inertia' AND (p.id = u.in_force_id OR p.status = 'open')
WHERE u.doc_id = ?2 AND p.kind <> 'inertia' AND (p.id = u.in_force_id OR p.status = 'open')
ORDER BY u.position, u.id",
)
.bind(me)
+21 -33
View File
@@ -14,12 +14,14 @@
use crate::AppState;
use crate::adoption;
use crate::auth::{IdentityRow, User};
use crate::ballots;
use crate::docs::{self, DocState, PROPOSAL_SELECT, ProposalRow, UnitRow};
use crate::error::{AppError, AppResult};
use crate::util;
use axum::Json;
use axum::extract::{Path, State};
use ld_engine::assist::{self, Hint};
use ld_engine::ballot::Choice;
use ld_engine::edit::{self, EditKind};
use ld_engine::inertia::InertiaLevel;
use serde::Deserialize;
@@ -616,12 +618,8 @@ pub async fn detail(
}));
}
let my_vote: Option<(String, i64)> =
sqlx::query_as("SELECT choice, revision FROM votes WHERE proposal_id = ? AND identity_id = ?")
.bind(&id)
.bind(&user.id)
.fetch_optional(&state.db)
.await?;
let my_vote = ballots::my_votes(&state, &user.id, &st.doc.id).await?.remove(&id);
let my_ballot = ballots::my_ballots(&state, &user.id, &st.doc.id).await?.remove(&id);
let diff = match (&in_force, p.kind.as_str()) {
(Some(v), "text") => Some(edit::diff(&v.body, &p.body)),
_ => None,
@@ -640,7 +638,9 @@ pub async fn detail(
"seedMinimal": p.status == "in_force" && p.entered_at.is_none() && p.confirmed_at.is_none() && p.origin == "seed",
"tally": st.tally(&p.id), "evaluation": eval, "votedAtInertia": voted_at,
"blocked": st.blocked.contains(&p.id),
"myVote": my_vote.as_ref().map(|v| &v.0), "myVoteStale": my_vote.as_ref().is_some_and(|v| v.1 != p.revision),
"myVote": my_vote.as_ref().map(|v| &v.choice),
"myVoteStale": my_vote.as_ref().is_some_and(|v| v.revision != p.revision),
"myVoteChain": my_vote.as_ref().is_some_and(|v| v.source == "chain"), "myBallot": my_ballot,
"translations": tr.get(&p.id).cloned().unwrap_or_default(),
},
"unit": { "id": unit.id, "code": unit.code, "inertia": docs::inertia_of(unit) },
@@ -666,6 +666,8 @@ pub struct VoteBody {
choice: Option<String>,
}
/// One vote on the server, in a managed group. Where the vote goes through the
/// chain (Monnaie libre), it is a ballot (`ballots::create`), never this.
pub async fn vote(
State(state): State<AppState>,
User(user): User,
@@ -673,37 +675,23 @@ pub async fn vote(
Json(b): Json<VoteBody>,
) -> AppResult<Json<serde_json::Value>> {
let (p, st) = guarded(&state, &user, &id).await?;
if trustin_access::vote_way(&st.group.to_group()) == trustin_access::VoteWay::Chain {
return Err(AppError::Conflict("chain_only"));
}
if !matches!(p.status.as_str(), "open" | "in_force") {
return Err(AppError::Conflict("not_votable"));
}
// Withdrawing one's vote is always possible; casting one follows trustinWallet.
if b.choice.is_some() && !docs::may_vote(&state, &user, &st.group).await? {
return Err(AppError::Conflict("wot_members_only"));
if !docs::may_vote(&state, &user, &st.group).await? {
return Err(AppError::Conflict("members_only"));
}
match b.choice.as_deref() {
Some(choice @ ("for" | "against")) => {
sqlx::query(
"INSERT INTO votes (proposal_id, identity_id, choice, revision, cast_at) VALUES (?, ?, ?, ?, ?)
ON CONFLICT(proposal_id, identity_id) DO UPDATE SET choice = excluded.choice,
revision = excluded.revision, cast_at = excluded.cast_at",
)
.bind(&id)
.bind(&user.id)
.bind(choice)
.bind(p.revision)
.bind(util::now())
.execute(&state.db)
.await?;
}
None => {
sqlx::query("DELETE FROM votes WHERE proposal_id = ? AND identity_id = ?")
.bind(&id)
.bind(&user.id)
.execute(&state.db)
.await?;
}
let choice = match b.choice.as_deref() {
Some("for") => Choice::For,
Some("against") => Choice::Against,
None => Choice::None,
Some(_) => return Err(AppError::Invalid("choice")),
}
};
let cast = ballots::Cast { proposal_id: p.id.clone(), revision: p.revision, choice };
ballots::write_server_votes(&state, &user.id, &[cast]).await?;
let entered = adoption::run(&state, &st.doc.id).await?;
Ok(Json(json!({ "entered": entered })))
}
+11 -22
View File
@@ -125,22 +125,6 @@ async fn the_seed_a_minima_and_the_open_proposals() {
assert!(e1["translations"]["en"]["body"].as_str().expect("en").contains("commit"));
}
#[tokio::test]
async fn one_vote_never_adopts_in_monnaie_libre() {
let (mut c, _) = Client::new().await;
c.login("Yvv", false).await;
let id = doc_id(&mut c, "monnaie-libre").await;
let (_, doc) = c.call("GET", &format!("/api/docs/{id}"), None).await;
let p = unit(&doc, "E3")["proposals"][0]["id"].as_str().expect("p").to_owned();
let (s, v) = c.call("POST", &format!("/api/proposals/{p}/vote"), Some(json!({ "choice": "for" }))).await;
assert_eq!(s, StatusCode::OK, "{v}");
assert_eq!(v["entered"].as_array().expect("e").len(), 0);
let (_, d) = c.call("GET", &format!("/api/proposals/{p}"), None).await;
assert_eq!(d["proposal"]["evaluation"]["required"], 50);
assert_eq!(d["proposal"]["evaluation"]["wellRated"], true);
assert_eq!(d["proposal"]["myVote"], "for");
}
#[tokio::test]
async fn adoption_in_the_test_group_and_publication_queued_only_where_published() {
let (mut a, state) = Client::new().await;
@@ -304,10 +288,13 @@ async fn editing_is_not_voting() {
.await;
assert_eq!((s, v["error"].as_str()), (StatusCode::CONFLICT, Some("substantive_not_author")));
// A member proposal: typo keeps votes, substance asks to reconfirm.
let uid = unit(&doc, "E2")["id"].as_str().expect("u").to_owned();
// A member proposal: typo keeps votes, substance asks to reconfirm (in the test group:
// Monnaie libre votes on the chain, tests/ballots.rs).
let essai = doc_id(&mut a, "essai").await;
let (_, essai_doc) = a.call("GET", &format!("/api/docs/{essai}"), None).await;
let uid = unit(&essai_doc, "E2")["id"].as_str().expect("u").to_owned();
let (_, v) = a
.call("POST", &format!("/api/docs/{id}/proposals"), Some(json!({
.call("POST", &format!("/api/docs/{essai}/proposals"), Some(json!({
"unitId": uid, "title": "Réciprocité", "body": "Je m'engage à ne certifier que des persones qui respectent ces engagements.", "submit": true,
})))
.await;
@@ -690,7 +677,7 @@ async fn a_team_link_makes_one_identity_per_opening() {
}
#[tokio::test]
async fn in_monnaie_libre_only_web_of_trust_members_vote() {
async fn in_monnaie_libre_an_invitation_never_votes_on_the_server() {
let (mut admin, _) = Client::new().await;
admin.login("Yvv", true).await;
let (_, v) = admin
@@ -700,15 +687,17 @@ async fn in_monnaie_libre_only_web_of_trust_members_vote() {
let mut invited = admin.with(None);
let (s, _) = invited.call("POST", "/api/auth/invitation", Some(json!({ "token": token }))).await;
assert_eq!(s, StatusCode::OK);
// She reads the text and may propose, but her vote would not be a web-of-trust vote.
// She reads the text and may propose; her vote would go through the chain, for the Ğ1
// identity of her wallet (tests/ballots.rs) — off here, without a vote account.
let id = doc_id(&mut invited, "monnaie-libre").await;
let (s, doc) = invited.call("GET", &format!("/api/docs/{id}"), None).await;
assert_eq!(s, StatusCode::OK);
assert_eq!(doc["canVote"], false);
assert_eq!((doc["vote"]["way"].as_str(), doc["vote"]["reason"].as_str()), (Some("chain"), Some("chain_off")));
let p = unit(&doc, "E3")["proposals"][0]["id"].as_str().expect("p").to_owned();
let (s, v) = invited.call("POST", &format!("/api/proposals/{p}/vote"), Some(json!({ "choice": "for" }))).await;
assert_eq!(s, StatusCode::CONFLICT, "{v}");
assert_eq!(v["error"], "wot_members_only");
assert_eq!(v["error"], "chain_only");
// In the test group, every member votes.
let essai = doc_id(&mut admin, "essai").await;
let (_, d) = admin.call("GET", &format!("/api/docs/{essai}"), None).await;
+787
View File
@@ -0,0 +1,787 @@
//! The vote, grouped, and the vote on the Ğ1 chain (ADR 0008): sealed by the
//! server, confirmed by reading an indexer — here a small simulated indexer that
//! answers the GraphQL query in the shape of the real one
//! (`tests/fixtures/indexer-merci.json`, relevé on indexer.duniter.org).
use axum::body::Body;
use axum::extract::State;
use axum::http::{Request, StatusCode, header};
use axum::response::{IntoResponse, Response};
use axum::routing::post;
use axum::{Json, Router};
use http_body_util::BodyExt;
use ld_engine::address::{self, G1_PREFIX};
use ld_engine::ballot::{blake2_256, to_hex};
use ld_server::config::VoteChain;
use ld_server::{AppState, CSRF_HEADER, CSRF_VALUE, Config, app, ballots, open_db, seed};
use serde_json::{Value, json};
use std::sync::{Arc, Mutex};
use tower::ServiceExt;
// Substrate's well-known development keys, as Ğ1 addresses: no real account is touched.
const VOTE_ACCOUNT: &str = "g1L5EYvvSBaYuQCPW78bHxYcvHfmWAqHYe3Gsrp9SV4kWZaEM"; // Dave
const ALICE: &str = "g1Pn1geRZnHcVdWdKkoYwQrAARDTXK1D7H3zuXj7GNayT1thu";
const ALICE_GENERIC: &str = "5GrwvaEF5zXb26Fz9rcQpDWS57CtERHpNehXCPcNoHGKutQY";
const BOB: &str = "g1NCrQaFReZcbUW8pKsazfWFCKNSdrTomnDraUMybJc4DFF5g";
const CHARLIE: &str = "g1NFWUkPTyZWE77YUrfBKfAhV9W81qmgdD17v1NGhb1EjYFqi";
const OTHER: &str = "g1QBoGEqZuxL77pF4rMa25f1cH6HAxwgBq66zZu76JPqsaVgj"; // Eve
// ---------------------------------------------------------------------------
// A simulated indexer
// ---------------------------------------------------------------------------
#[derive(Default)]
struct Chain {
comments: Vec<Value>,
silent: bool,
asked: usize,
}
#[derive(Clone)]
struct Indexer {
chain: Arc<Mutex<Chain>>,
url: String,
}
/// Who signed, and the identity of that account, as the indexer serves it.
struct Signer<'a> {
account: &'a str,
identity: Option<(u32, &'a str, bool)>,
}
async fn graphql(State(chain): State<Arc<Mutex<Chain>>>, Json(body): Json<Value>) -> Response {
let mut c = chain.lock().expect("lock");
c.asked += 1;
if c.silent {
return StatusCode::BAD_GATEWAY.into_response();
}
let hash = body["variables"]["hash"].as_str().unwrap_or_default().to_owned();
let mut nodes: Vec<Value> = c
.comments
.iter()
.filter(|n| format!("0x{}", to_hex(&blake2_256(n["remark"].as_str().unwrap_or_default().as_bytes()))) == hash)
.cloned()
.collect();
nodes.sort_by_key(|n| n["blockNumber"].as_u64());
Json(json!({ "data": { "txComments": { "nodes": nodes } } })).into_response()
}
impl Indexer {
async fn start() -> Self {
let chain = Arc::new(Mutex::new(Chain::default()));
let router = Router::new().route("/v1/graphql", post(graphql)).with_state(chain.clone());
let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.expect("bind");
let addr = listener.local_addr().expect("addr");
tokio::spawn(async move { axum::serve(listener, router).await.expect("serve") });
Self { chain, url: format!("http://{addr}/v1/graphql") }
}
/// A transaction on the chain: the remark, signed by `signer`, with its transfers.
fn add(&self, block: u32, event: u32, remark: &str, signer: Signer<'_>, transfers: &[(&str, u64)]) {
let identity =
signer.identity.map(|(index, name, member)| json!({ "index": index, "name": name, "isMember": member }));
let transfers: Vec<Value> = transfers
.iter()
.map(|(to, amount)| json!({ "fromId": signer.account, "toId": to, "amount": amount.to_string() }))
.collect();
self.chain.lock().expect("lock").comments.push(json!({
"id": format!("{block:010}-abcde-{event:06}"), "blockNumber": block, "remark": remark,
"author": { "id": signer.account, "identity": identity },
"transfersByCommentId": { "nodes": transfers },
}));
}
/// The usual vote: 0.1 Ğ1 to the vote account.
fn pay(&self, block: u32, remark: &str, signer: Signer<'_>) {
self.add(block, 1, remark, signer, &[(VOTE_ACCOUNT, 10)]);
}
fn silent(&self, on: bool) {
self.chain.lock().expect("lock").silent = on;
}
fn asked(&self) -> usize {
self.chain.lock().expect("lock").asked
}
}
fn alice() -> Signer<'static> {
Signer { account: ALICE, identity: Some((9001, "Alice", true)) }
}
fn chain_config(indexer: &Indexer) -> Config {
let mut cfg = Config::for_tests();
cfg.indexers = vec![indexer.url.clone()];
cfg.vote = VoteChain::new(VOTE_ACCOUNT, 10, vec!["wss://node.invalid".into()], None);
cfg
}
// ---------------------------------------------------------------------------
// A client of the API
// ---------------------------------------------------------------------------
struct Client {
router: axum::Router,
cookie: Option<String>,
}
impl Client {
async fn new(cfg: Config) -> (Self, AppState) {
let db = open_db("sqlite::memory:").await.expect("db");
let state = AppState::new(cfg, db).await.expect("state");
seed::ensure(&state).await.expect("seed");
(Self { router: app(state.clone()), cookie: None }, state)
}
fn other(&self) -> Self {
Self { router: self.router.clone(), cookie: None }
}
async fn call(&mut self, method: &str, uri: &str, body: Option<Value>) -> (StatusCode, Value) {
let mut req = Request::builder().method(method).uri(uri).header(CSRF_HEADER, CSRF_VALUE);
if let Some(c) = &self.cookie {
req = req.header(header::COOKIE, c);
}
let req = match body {
Some(b) => req.header(header::CONTENT_TYPE, "application/json").body(Body::from(b.to_string())),
None => req.body(Body::empty()),
}
.expect("request");
let res = self.router.clone().oneshot(req).await.expect("response");
let status = res.status();
if let Some(set) = res.headers().get(header::SET_COOKIE) {
let raw = set.to_str().expect("cookie").split(';').next().unwrap_or_default().to_owned();
if raw.starts_with("ld_session=") {
self.cookie = Some(raw);
}
}
let bytes = res.into_body().collect().await.expect("body").to_bytes();
(status, serde_json::from_slice(&bytes).unwrap_or(Value::Null))
}
async fn ok(&mut self, method: &str, uri: &str, body: Option<Value>) -> Value {
let (s, v) = self.call(method, uri, body).await;
assert_eq!(s, StatusCode::OK, "{method} {uri}: {v}");
v
}
async fn login(&mut self, name: &str) {
self.ok("POST", "/api/auth/dev", Some(json!({ "name": name, "admin": false }))).await;
}
async fn doc(&mut self, group: &str) -> Value {
let g = self.ok("GET", &format!("/api/groups/{group}"), None).await;
let id = g["docs"][0]["id"].as_str().expect("doc").to_owned();
self.ok("GET", &format!("/api/docs/{id}"), None).await
}
/// A pending ballot for `lines` (version id, choice).
async fn ballot(&mut self, doc: &Value, lines: &[(&str, Option<&str>)], author: Option<&str>) -> Value {
let id = doc["doc"]["id"].as_str().expect("id");
let lines: Vec<Value> = lines.iter().map(|(p, c)| json!({ "proposalId": p, "choice": c })).collect();
self.ok("POST", &format!("/api/docs/{id}/ballots"), Some(json!({ "lines": lines, "author": author }))).await["ballot"]
.clone()
}
async fn status(&mut self, ballot: &Value) -> Value {
let id = ballot["id"].as_str().expect("id");
self.ok("GET", &format!("/api/ballots/{id}"), None).await["ballot"].clone()
}
}
fn unit<'a>(doc: &'a Value, code: &str) -> &'a Value {
doc["units"].as_array().expect("units").iter().find(|u| u["code"] == code).expect("unit")
}
/// The id of a version: `inForce`, or the n-th open proposal.
fn version(doc: &Value, code: &str, which: &str) -> String {
let u = unit(doc, code);
let v = if which == "inForce" { &u["inForce"] } else { &u["proposals"][which.parse::<usize>().expect("n")] };
v["id"].as_str().expect("id").to_owned()
}
/// The version as the document shows it now, by id.
fn find<'a>(doc: &'a Value, id: &str) -> &'a Value {
doc["units"]
.as_array()
.expect("units")
.iter()
.flat_map(|u| std::iter::once(&u["inForce"]).chain(u["proposals"].as_array().expect("p").iter()))
.find(|p| p["id"] == id)
.expect("version")
}
fn tally(doc: &Value, id: &str) -> (u64, u64) {
let t = &find(doc, id)["tally"];
(t["for"].as_u64().expect("for"), t["against"].as_u64().expect("against"))
}
async fn rewind_check(state: &AppState) {
// The page asks often, the indexer is read at most every few seconds: tests rewind that clock.
sqlx::query("UPDATE ballots SET checked_at = NULL").execute(&state.db).await.expect("rewind");
}
// ---------------------------------------------------------------------------
// The vote on the chain
// ---------------------------------------------------------------------------
#[tokio::test]
async fn a_grouped_ballot_is_sealed_then_confirmed_by_the_indexer() {
let indexer = Indexer::start().await;
let (mut c, state) = Client::new(chain_config(&indexer)).await;
c.login("Chiara").await;
let doc = c.doc("monnaie-libre").await;
// Monnaie libre votes on the chain; this session may ask for a ballot.
assert_eq!(doc["vote"]["way"], "chain");
assert_eq!(doc["vote"]["open"], true);
assert_eq!(doc["canVote"], true);
assert_eq!(doc["vote"]["chain"]["account"], VOTE_ACCOUNT);
assert_eq!(doc["vote"]["chain"]["amount"], 10);
assert_eq!(doc["vote"]["chain"]["test"], false);
let i1 = version(&doc, "I1", "inForce");
let e3 = version(&doc, "E3", "0");
let b = c.ballot(&doc, &[(&i1, Some("for")), (&e3, Some("against"))], Some(ALICE_GENERIC)).await;
let comment = b["comment"].as_str().expect("comment");
assert!(comment.starts_with("ld1:") && comment.len() == 68 && comment[4..].bytes().all(|x| x.is_ascii_hexdigit()));
assert_eq!(b["status"], "pending");
assert_eq!(b["account"], VOTE_ACCOUNT);
assert_eq!(b["author"], ALICE, "the bound account, written as a Ğ1 address");
assert_eq!(b["lines"], 2);
// What the server keeps: the lines, the salt, and the seal they make.
let (digest, salt): (String, String) =
sqlx::query_as("SELECT digest, salt FROM ballots").fetch_one(&state.db).await.expect("ballot");
assert_eq!(format!("ld1:{digest}"), comment);
assert_eq!(salt.len(), 32);
let doc_id = doc["doc"]["id"].as_str().expect("id");
let lines = [
ld_engine::ballot::Line { proposal_id: i1.clone(), revision: 1, choice: ld_engine::ballot::Choice::For },
ld_engine::ballot::Line { proposal_id: e3.clone(), revision: 1, choice: ld_engine::ballot::Choice::Against },
];
assert_eq!(ld_engine::ballot::seal(doc_id, Some(ALICE), &lines, &salt).expect("seal").comment, comment);
// Nothing on the chain yet: pending, nothing counted.
assert_eq!(c.status(&b).await["status"], "pending");
let doc = c.doc("monnaie-libre").await;
assert_eq!(tally(&doc, &i1), (0, 0));
assert_eq!(find(&doc, &i1)["myBallot"]["status"], "pending");
assert_eq!(find(&doc, &i1)["myBallot"]["choice"], "for");
// The transaction lands: one transaction for both lines.
indexer.pay(3_400_000, comment, alice());
rewind_check(&state).await;
let s = c.status(&b).await;
assert_eq!(s["status"], "confirmed", "{s}");
assert_eq!(s["voter"], "Alice");
assert_eq!(s["block"], 3_400_000);
let doc = c.doc("monnaie-libre").await;
assert_eq!(tally(&doc, &i1), (1, 0));
assert_eq!(tally(&doc, &e3), (0, 1));
// The session that asked sees « ton vote », confirmed on the chain.
assert_eq!(find(&doc, &i1)["myVote"], "for");
assert_eq!(find(&doc, &i1)["myVoteChain"], true);
assert!(find(&doc, &i1)["myBallot"].is_null());
assert_eq!(find(&doc, &e3)["myVote"], "against");
// The vote counts for the Ğ1 identity of the signer, created as a member.
let row: (String, String, bool, Option<String>) =
sqlx::query_as("SELECT kind, display_name, g1_member, address FROM identities WHERE id = 'idty:9001'")
.fetch_one(&state.db)
.await
.expect("identity");
assert_eq!(row, ("g1".into(), "Alice".into(), true, Some(ALICE.into())));
let voters: Vec<(String, String)> = sqlx::query_as("SELECT identity_id, source FROM votes ORDER BY proposal_id")
.fetch_all(&state.db)
.await
.expect("votes");
assert!(voters.iter().all(|(who, source)| who == "idty:9001" && source == "chain"), "{voters:?}");
// One voice never makes a text pass (C = 50).
assert_eq!(find(&doc, &i1)["evaluation"]["required"], 50);
// Someone else sees the totals, not the vote.
let mut yvv = c.other();
yvv.login("Yvv").await;
let seen = yvv.doc("monnaie-libre").await;
assert_eq!(tally(&seen, &i1), (1, 0));
assert!(find(&seen, &i1)["myVote"].is_null());
// Asked again, a confirmed ballot stays as it is.
rewind_check(&state).await;
assert_eq!(c.status(&b).await["status"], "confirmed");
assert_eq!(
yvv.call("GET", &format!("/api/ballots/{}", b["id"].as_str().expect("id")), None).await.0,
StatusCode::NOT_FOUND
);
}
#[tokio::test]
async fn one_voice_on_the_chain_never_adopts_in_monnaie_libre() {
let indexer = Indexer::start().await;
let (mut c, state) = Client::new(chain_config(&indexer)).await;
c.login("Yvv").await;
let doc = c.doc("monnaie-libre").await;
let p = version(&doc, "E3", "0");
let b = c.ballot(&doc, &[(&p, Some("for"))], None).await;
indexer.pay(100, b["comment"].as_str().expect("c"), alice());
ballots::sweep(&state).await.expect("sweep");
let s = c.status(&b).await;
assert_eq!(s["status"], "confirmed");
assert_eq!(s["entered"], json!([]));
let d = c.ok("GET", &format!("/api/proposals/{p}"), None).await;
assert_eq!(d["proposal"]["evaluation"]["required"], 50);
assert_eq!(d["proposal"]["evaluation"]["wellRated"], true);
assert_eq!(d["proposal"]["myVote"], "for");
assert_eq!(d["proposal"]["myVoteChain"], true);
}
#[tokio::test]
async fn a_silent_indexer_keeps_the_ballot_pending() {
let indexer = Indexer::start().await;
let (mut c, state) = Client::new(chain_config(&indexer)).await;
c.login("Chiara").await;
let doc = c.doc("monnaie-libre").await;
let i1 = version(&doc, "I1", "inForce");
let b = c.ballot(&doc, &[(&i1, Some("for"))], None).await;
indexer.pay(100, b["comment"].as_str().expect("comment"), alice());
indexer.silent(true);
assert_eq!(c.status(&b).await["status"], "pending");
assert!(indexer.asked() >= 1);
// Asked again at once: the indexer is not read again before a few seconds.
let asked = indexer.asked();
assert_eq!(c.status(&b).await["status"], "pending");
assert_eq!(indexer.asked(), asked);
// The background task reads it again; silent still, nothing changes.
assert_eq!(ballots::sweep(&state).await.expect("sweep"), 1);
assert_eq!(c.status(&b).await["status"], "pending");
// The indexer is back.
indexer.silent(false);
ballots::sweep(&state).await.expect("sweep");
assert_eq!(c.status(&b).await["status"], "confirmed");
assert_eq!(ballots::sweep(&state).await.expect("sweep"), 0, "nothing left to read");
}
#[tokio::test]
async fn a_signer_who_is_not_a_member_is_refused_and_told() {
let indexer = Indexer::start().await;
let (mut c, state) = Client::new(chain_config(&indexer)).await;
c.login("Chiara").await;
let doc = c.doc("monnaie-libre").await;
let i1 = version(&doc, "I1", "inForce");
let former = c.ballot(&doc, &[(&i1, Some("for"))], None).await;
indexer.pay(
100,
former["comment"].as_str().expect("c"),
Signer { account: BOB, identity: Some((42, "Bob", false)) },
);
let nobody = c.ballot(&doc, &[(&i1, Some("for"))], None).await;
indexer.pay(101, nobody["comment"].as_str().expect("c"), Signer { account: BOB, identity: None });
ballots::sweep(&state).await.expect("sweep");
let s = c.status(&former).await;
assert_eq!((s["status"].as_str(), s["reason"].as_str()), (Some("refused"), Some("not_member")));
let s = c.status(&nobody).await;
assert_eq!((s["status"].as_str(), s["reason"].as_str()), (Some("refused"), Some("no_identity")));
let doc = c.doc("monnaie-libre").await;
assert_eq!(tally(&doc, &i1), (0, 0));
// The page says so on the version.
assert_eq!(find(&doc, &i1)["myBallot"]["status"], "refused");
assert_eq!(find(&doc, &i1)["myBallot"]["reason"], "no_identity");
let n: i64 = sqlx::query_scalar("SELECT COUNT(*) FROM identities WHERE id = 'idty:42'")
.fetch_one(&state.db)
.await
.expect("n");
assert_eq!(n, 0, "no identity is created for a refused ballot");
}
#[tokio::test]
async fn a_wrong_amount_or_recipient_is_refused() {
let indexer = Indexer::start().await;
let (mut c, state) = Client::new(chain_config(&indexer)).await;
c.login("Chiara").await;
let doc = c.doc("monnaie-libre").await;
let i1 = version(&doc, "I1", "inForce");
let short = c.ballot(&doc, &[(&i1, Some("for"))], None).await;
indexer.add(100, 1, short["comment"].as_str().expect("c"), alice(), &[(VOTE_ACCOUNT, 9)]);
let elsewhere = c.ballot(&doc, &[(&i1, Some("for"))], None).await;
indexer.add(101, 1, elsewhere["comment"].as_str().expect("c"), alice(), &[(OTHER, 10)]);
let remark_only = c.ballot(&doc, &[(&i1, Some("for"))], None).await;
indexer.add(102, 1, remark_only["comment"].as_str().expect("c"), alice(), &[]);
ballots::sweep(&state).await.expect("sweep");
for b in [&short, &elsewhere, &remark_only] {
let s = c.status(b).await;
assert_eq!((s["status"].as_str(), s["reason"].as_str()), (Some("refused"), Some("transfer")), "{s}");
}
assert_eq!(tally(&c.doc("monnaie-libre").await, &i1), (0, 0));
}
#[tokio::test]
async fn confirming_twice_changes_nothing() {
let indexer = Indexer::start().await;
let (mut c, state) = Client::new(chain_config(&indexer)).await;
c.login("Chiara").await;
let doc = c.doc("monnaie-libre").await;
let i1 = version(&doc, "I1", "inForce");
let b = c.ballot(&doc, &[(&i1, Some("for"))], None).await;
indexer.pay(100, b["comment"].as_str().expect("c"), alice());
// The page and the background task at once.
let row = sqlx::query_as::<_, ballots::BallotRow>(
"SELECT id, doc_id, requested_by, author, digest, remark_hash, account, amount, status, reason, voter_id, block,
entered, created_at, expires_at, checked_at FROM ballots",
)
.fetch_one(&state.db)
.await
.expect("row");
let (a, b2) = tokio::join!(ballots::check(&state, &row), ballots::check(&state, &row));
a.expect("check");
b2.expect("check");
ballots::check(&state, &row).await.expect("again");
assert_eq!(c.status(&b).await["status"], "confirmed");
let n: i64 = sqlx::query_scalar("SELECT COUNT(*) FROM votes").fetch_one(&state.db).await.expect("n");
assert_eq!(n, 1);
assert_eq!(tally(&c.doc("monnaie-libre").await, &i1), (1, 0));
}
#[tokio::test]
async fn the_last_one_stands_in_the_order_of_the_chain() {
let indexer = Indexer::start().await;
let (mut c, state) = Client::new(chain_config(&indexer)).await;
c.login("Chiara").await;
let doc = c.doc("monnaie-libre").await;
let i1 = version(&doc, "I1", "inForce");
let e1 = version(&doc, "E1", "inForce");
// Three ballots of the same person, signed in this order on the chain: for (I1, E1), against (I1), none (E1).
let first = c.ballot(&doc, &[(&i1, Some("for")), (&e1, Some("for"))], None).await;
let second = c.ballot(&doc, &[(&i1, Some("against"))], None).await;
let third = c.ballot(&doc, &[(&e1, None)], None).await;
// The most recent is confirmed first; the oldest last, as when an indexer lags behind.
indexer.pay(300, third["comment"].as_str().expect("c"), alice());
ballots::sweep(&state).await.expect("sweep");
indexer.add(200, 4, second["comment"].as_str().expect("c"), alice(), &[(VOTE_ACCOUNT, 10)]);
ballots::sweep(&state).await.expect("sweep");
let doc = c.doc("monnaie-libre").await;
assert_eq!((tally(&doc, &i1), tally(&doc, &e1)), ((0, 1), (0, 0)));
indexer.add(200, 2, first["comment"].as_str().expect("c"), alice(), &[(VOTE_ACCOUNT, 10)]);
ballots::sweep(&state).await.expect("sweep");
for b in [&first, &second, &third] {
assert_eq!(c.status(b).await["status"], "confirmed");
}
// Block 200 event 2 comes before block 200 event 4, before block 300: the first ballot
// decides nothing any more. I1 stays « against »; E1 stays withdrawn, never brought back.
let doc = c.doc("monnaie-libre").await;
assert_eq!(tally(&doc, &i1), (0, 1));
assert_eq!(tally(&doc, &e1), (0, 0));
assert_eq!(find(&doc, &i1)["myVote"], "against");
assert!(find(&doc, &e1)["myVote"].is_null());
// A new ballot, later on the chain, decides again.
let fourth = c.ballot(&doc, &[(&e1, Some("against"))], None).await;
indexer.pay(301, fourth["comment"].as_str().expect("c"), alice());
ballots::sweep(&state).await.expect("sweep");
assert_eq!(tally(&c.doc("monnaie-libre").await, &e1), (0, 1));
}
#[tokio::test]
async fn a_revision_changed_meanwhile_leaves_the_vote_to_reconfirm() {
let indexer = Indexer::start().await;
let (mut c, state) = Client::new(chain_config(&indexer)).await;
c.login("Chiara").await;
let doc = c.doc("monnaie-libre").await;
let doc_id = doc["doc"]["id"].as_str().expect("id").to_owned();
let created = c
.ok(
"POST",
&format!("/api/docs/{doc_id}/proposals"),
Some(json!({ "title": "Joignabilité", "body": "Je m'engage à rester joignable par mes certifieurs.", "submit": true })),
)
.await;
let p = created["id"].as_str().expect("id").to_owned();
let doc = c.doc("monnaie-libre").await;
let b = c.ballot(&doc, &[(&p, Some("for"))], None).await;
// The author changes its meaning before the transaction is confirmed.
let edit = c
.ok(
"PATCH",
&format!("/api/proposals/{p}"),
Some(
json!({ "title": "Joignabilité", "body": "Je m'engage à répondre à mes certifieurs sous huit jours." }),
),
)
.await;
assert_eq!(edit["edit"], "substantive");
indexer.pay(100, b["comment"].as_str().expect("c"), alice());
ballots::sweep(&state).await.expect("sweep");
assert_eq!(c.status(&b).await["status"], "confirmed");
// The vote was cast on revision 1: it waits to be reconfirmed, as any vote does.
let doc = c.doc("monnaie-libre").await;
assert_eq!(tally(&doc, &p), (0, 0));
assert_eq!(find(&doc, &p)["myVote"], "for");
assert_eq!(find(&doc, &p)["myVoteStale"], true);
let rev: i64 = sqlx::query_scalar("SELECT revision FROM votes WHERE proposal_id = ?")
.bind(&p)
.fetch_one(&state.db)
.await
.expect("vote");
assert_eq!(rev, 1);
}
#[tokio::test]
async fn a_bound_ballot_ignores_a_copy_of_its_remark_by_another_account() {
let indexer = Indexer::start().await;
let (mut c, state) = Client::new(chain_config(&indexer)).await;
c.login("Chiara").await;
let doc = c.doc("monnaie-libre").await;
let i1 = version(&doc, "I1", "inForce");
let b = c.ballot(&doc, &[(&i1, Some("for"))], Some(ALICE)).await;
let comment = b["comment"].as_str().expect("c");
// Charlie copies the remark seen in the pool and gets it in first.
indexer.pay(99, comment, Signer { account: CHARLIE, identity: Some((7, "Charlie", true)) });
ballots::sweep(&state).await.expect("sweep");
assert_eq!(c.status(&b).await["status"], "pending", "a copy settles nothing");
indexer.pay(100, comment, alice());
ballots::sweep(&state).await.expect("sweep");
let s = c.status(&b).await;
assert_eq!((s["status"].as_str(), s["voter"].as_str()), (Some("confirmed"), Some("Alice")));
let voters: Vec<String> =
sqlx::query_scalar("SELECT identity_id FROM votes").fetch_all(&state.db).await.expect("v");
assert_eq!(voters, ["idty:9001"]);
}
#[tokio::test]
async fn a_ballot_not_seen_in_time_is_let_go() {
let indexer = Indexer::start().await;
let (mut c, state) = Client::new(chain_config(&indexer)).await;
c.login("Chiara").await;
let doc = c.doc("monnaie-libre").await;
let i1 = version(&doc, "I1", "inForce");
let b = c.ballot(&doc, &[(&i1, Some("for"))], None).await;
let expires = b["expiresAt"].as_str().expect("expires");
assert!(
expires > ld_server::util::in_seconds(23 * 3600).as_str()
&& expires <= ld_server::util::in_seconds(24 * 3600).as_str()
);
sqlx::query("UPDATE ballots SET expires_at = ?")
.bind(ld_server::util::in_seconds(-1))
.execute(&state.db)
.await
.expect("age");
assert_eq!(ballots::sweep(&state).await.expect("sweep"), 0);
assert_eq!(c.status(&b).await["status"], "expired");
// Seen on the chain afterwards: too late, it no longer counts.
indexer.pay(100, b["comment"].as_str().expect("c"), alice());
ballots::sweep(&state).await.expect("sweep");
assert_eq!(c.status(&b).await["status"], "expired");
assert_eq!(find(&c.doc("monnaie-libre").await, &i1)["myBallot"]["status"], "expired");
}
#[tokio::test]
async fn a_session_sees_its_own_ballots_only() {
let indexer = Indexer::start().await;
let (mut chiara, state) = Client::new(chain_config(&indexer)).await;
chiara.login("Chiara").await;
let mut other = chiara.other();
other.login("Yvv").await;
let doc = chiara.doc("monnaie-libre").await;
let (i1, e1) = (version(&doc, "I1", "inForce"), version(&doc, "E1", "inForce"));
// Both sign with the same wallet (one person, two sessions): two ballots, two versions.
let a = chiara.ballot(&doc, &[(&i1, Some("for"))], None).await;
let b = other.ballot(&doc, &[(&e1, Some("against"))], None).await;
indexer.pay(100, a["comment"].as_str().expect("c"), alice());
indexer.pay(101, b["comment"].as_str().expect("c"), alice());
ballots::sweep(&state).await.expect("sweep");
let mine = chiara.doc("monnaie-libre").await;
assert_eq!((find(&mine, &i1)["myVote"].as_str(), find(&mine, &e1)["myVote"].as_str()), (Some("for"), None));
let theirs = other.doc("monnaie-libre").await;
assert_eq!((find(&theirs, &i1)["myVote"].as_str(), find(&theirs, &e1)["myVote"].as_str()), (None, Some("against")));
// « Décisions » counts the same way.
let d = chiara.ok("GET", "/api/groups/monnaie-libre/decisions", None).await;
assert_eq!(d["canVote"], true);
assert_eq!((d["docs"][0]["toVote"].as_u64(), d["docs"][0]["withoutMyVote"].as_u64()), (Some(33), Some(32)));
}
#[tokio::test]
async fn fifty_voices_on_the_chain_make_a_text_pass_and_a_signal_holds_it() {
let indexer = Indexer::start().await;
let (mut c, state) = Client::new(chain_config(&indexer)).await;
c.login("Chiara").await;
let doc = c.doc("monnaie-libre").await;
let e4 = version(&doc, "E4", "0");
let e5 = version(&doc, "E5", "0");
// A contradiction signalled on E5 against the text in force E1: E5 cannot enter.
let reason = "Ces deux textes disent le contraire l'un de l'autre.";
let e1 = version(&doc, "E1", "inForce");
c.ok("POST", &format!("/api/proposals/{e5}/signals"), Some(json!({ "targetId": e1, "reason": reason }))).await;
let keys: Vec<String> = (1..=50u8).map(|n| address::encode(G1_PREFIX, &[n; 32])).collect();
let mut last = Value::Null;
for (n, account) in keys.iter().enumerate() {
let b = c.ballot(&doc, &[(&e4, Some("for")), (&e5, Some("for"))], None).await;
let index = 10_000 + u32::try_from(n).expect("n");
indexer.pay(
1_000 + index,
b["comment"].as_str().expect("c"),
Signer { account, identity: Some((index, "Membre", true)) },
);
last = b;
if n % 10 == 9 {
// A session holds at most 20 pending ballots: the background task confirms on the way.
ballots::sweep(&state).await.expect("sweep");
}
if n == 48 {
ballots::sweep(&state).await.expect("sweep");
let d = c.doc("monnaie-libre").await;
assert_eq!(tally(&d, &e4), (49, 0));
assert!(find(&d, &e4)["enteredAt"].is_null(), "49 voices: below C = 50");
}
}
ballots::sweep(&state).await.expect("sweep");
let s = c.status(&last).await;
assert_eq!(s["status"], "confirmed");
assert_eq!(s["entered"][0]["code"], "E4", "{s}");
let d = c.doc("monnaie-libre").await;
assert_eq!(unit(&d, "E4")["inForce"]["id"], e4.as_str());
assert_eq!(tally(&d, &e5), (50, 0));
assert!(unit(&d, "E5")["inForce"].is_null(), "a signal holds it back");
}
// ---------------------------------------------------------------------------
// Without the chain, and in managed groups
// ---------------------------------------------------------------------------
#[tokio::test]
async fn without_a_vote_account_the_chain_vote_is_off_and_the_test_group_still_votes() {
let (mut c, _) = Client::new(Config::for_tests()).await;
c.login("Chiara").await;
let doc = c.doc("monnaie-libre").await;
assert_eq!(doc["vote"]["way"], "chain");
assert_eq!(doc["vote"]["open"], false);
assert_eq!(doc["vote"]["reason"], "chain_off");
assert!(doc["vote"]["chain"].is_null());
assert_eq!(doc["canVote"], false);
let doc_id = doc["doc"]["id"].as_str().expect("id").to_owned();
let i1 = version(&doc, "I1", "inForce");
let (s, v) = c
.call(
"POST",
&format!("/api/docs/{doc_id}/ballots"),
Some(json!({ "lines": [{ "proposalId": i1, "choice": "for" }] })),
)
.await;
assert_eq!((s, v["error"].as_str()), (StatusCode::CONFLICT, Some("chain_vote_off")));
let essai = c.doc("essai").await;
assert_eq!((essai["vote"]["way"].as_str(), essai["vote"]["open"].as_bool()), (Some("server"), Some(true)));
let p = version(&essai, "I1", "inForce");
c.ok("POST", &format!("/api/proposals/{p}/vote"), Some(json!({ "choice": "for" }))).await;
}
#[tokio::test]
async fn in_monnaie_libre_nobody_votes_on_the_server() {
let indexer = Indexer::start().await;
let (mut c, _) = Client::new(chain_config(&indexer)).await;
c.login("Chiara").await;
let doc = c.doc("monnaie-libre").await;
let doc_id = doc["doc"]["id"].as_str().expect("id").to_owned();
let p = version(&doc, "E3", "0");
for choice in [json!("for"), Value::Null] {
let (s, v) = c.call("POST", &format!("/api/proposals/{p}/vote"), Some(json!({ "choice": choice }))).await;
assert_eq!((s, v["error"].as_str()), (StatusCode::CONFLICT, Some("chain_only")));
}
let (s, v) = c
.call(
"POST",
&format!("/api/docs/{doc_id}/votes"),
Some(json!({ "lines": [{ "proposalId": p, "choice": "for" }] })),
)
.await;
assert_eq!((s, v["error"].as_str()), (StatusCode::CONFLICT, Some("chain_only")));
// And no ballot in a group that votes on the server.
let essai = c.doc("essai").await;
let id = essai["doc"]["id"].as_str().expect("id").to_owned();
let q = version(&essai, "E3", "0");
let (s, v) = c
.call(
"POST",
&format!("/api/docs/{id}/ballots"),
Some(json!({ "lines": [{ "proposalId": q, "choice": "for" }] })),
)
.await;
assert_eq!((s, v["error"].as_str()), (StatusCode::CONFLICT, Some("server_vote")));
}
#[tokio::test]
async fn what_a_ballot_may_not_hold() {
let indexer = Indexer::start().await;
let (mut c, _) = Client::new(chain_config(&indexer)).await;
c.login("Chiara").await;
let doc = c.doc("monnaie-libre").await;
let doc_id = doc["doc"]["id"].as_str().expect("id").to_owned();
let i1 = version(&doc, "I1", "inForce");
let essai = c.doc("essai").await;
let elsewhere = version(&essai, "I1", "inForce");
for (lines, author, code) in [
(json!([]), Value::Null, "lines"),
(
json!([{ "proposalId": i1, "choice": "for" }, { "proposalId": i1, "choice": "against" }]),
Value::Null,
"lines",
),
(json!([{ "proposalId": elsewhere, "choice": "for" }]), Value::Null, "lines"),
(json!([{ "proposalId": i1, "choice": "maybe" }]), Value::Null, "choice"),
(json!([{ "proposalId": i1, "choice": "for" }]), json!("g1NotAnAddress"), "author"),
] {
let (s, v) = c
.call("POST", &format!("/api/docs/{doc_id}/ballots"), Some(json!({ "lines": lines, "author": author })))
.await;
assert_eq!((s, v["error"].as_str()), (StatusCode::BAD_REQUEST, Some(code)), "{lines}");
}
// A session holds a bounded number of pending ballots.
for _ in 0..20 {
c.ballot(&doc, &[(&i1, Some("for"))], None).await;
}
let (s, v) = c
.call(
"POST",
&format!("/api/docs/{doc_id}/ballots"),
Some(json!({ "lines": [{ "proposalId": i1, "choice": "for" }] })),
)
.await;
assert_eq!((s, v["error"].as_str()), (StatusCode::CONFLICT, Some("too_many_ballots")));
}
#[tokio::test]
async fn a_grouped_vote_in_a_managed_group_is_one_write() {
let (mut a, state) = Client::new(Config::for_tests()).await;
a.login("Yvv").await;
let mut b = a.other();
b.login("Chiara").await;
let doc = a.doc("essai").await;
let id = doc["doc"]["id"].as_str().expect("id").to_owned();
let (e4, e5, e6) = (version(&doc, "E4", "0"), version(&doc, "E5", "0"), version(&doc, "E6", "0"));
let lines = json!([
{ "proposalId": e4, "choice": "for" }, { "proposalId": e5, "choice": "against" }, { "proposalId": e6, "choice": "for" },
]);
let v = a.ok("POST", &format!("/api/docs/{id}/votes"), Some(json!({ "lines": lines }))).await;
assert_eq!(v["count"], 3);
assert!(v["entered"].as_array().expect("e").is_empty(), "one voice never makes a text pass");
// All or nothing: one line that does not hold, and no line is written.
let (s, _) = b
.call(
"POST",
&format!("/api/docs/{id}/votes"),
Some(
json!({ "lines": [{ "proposalId": e4, "choice": "for" }, { "proposalId": "nope", "choice": "for" }] }),
),
)
.await;
assert_eq!(s, StatusCode::BAD_REQUEST);
let n: i64 = sqlx::query_scalar("SELECT COUNT(*) FROM votes WHERE identity_id = 'dev:chiara'")
.fetch_one(&state.db)
.await
.expect("n");
assert_eq!(n, 0);
// The second voice of a group of two: E4 and E6 enter at once; withdrawing in the same gesture.
let lines = json!([
{ "proposalId": e4, "choice": "for" }, { "proposalId": e6, "choice": "for" }, { "proposalId": e5, "choice": null },
]);
let v = b.ok("POST", &format!("/api/docs/{id}/votes"), Some(json!({ "lines": lines }))).await;
let mut codes: Vec<&str> = v["entered"].as_array().expect("e").iter().filter_map(|e| e["code"].as_str()).collect();
codes.sort_unstable();
assert_eq!(codes, ["E4", "E6"]);
let doc = a.doc("essai").await;
assert_eq!(tally(&doc, &e5), (0, 1));
assert_eq!(find(&doc, &e5)["myVote"], "against");
assert_eq!(find(&doc, &e5)["myVoteChain"], false);
}
+92
View File
@@ -0,0 +1,92 @@
{
"data": {
"txComments": {
"nodes": [
{
"id": "0000196944-60692-000003",
"blockNumber": 196944,
"remark": "Merci ",
"author": {
"id": "g1NrqKgJW9FoFabaYW82zhS9tDWdQcL1DGpivaJdj7GW9M6iv",
"identity": {
"index": 1304,
"name": "SIMONEANTONELLI",
"isMember": true
}
},
"transfersByCommentId": {
"nodes": [
{
"fromId": "g1NrqKgJW9FoFabaYW82zhS9tDWdQcL1DGpivaJdj7GW9M6iv",
"toId": "g1MuCTPqrcsESdhj6vZeApbqGmJG5V3Hj56cwMUmyiM7WYQe7",
"amount": "30000"
}
]
}
},
{
"id": "0000243074-e094a-000007",
"blockNumber": 243074,
"remark": "Merci ",
"author": {
"id": "g1Kdm8ffnMrk3p8btJhfV48Kd4fUkyMi6nD7NtFpCCPfcQsoB",
"identity": {
"index": 3947,
"name": "Jade971",
"isMember": true
}
},
"transfersByCommentId": {
"nodes": [
{
"fromId": "g1Kdm8ffnMrk3p8btJhfV48Kd4fUkyMi6nD7NtFpCCPfcQsoB",
"toId": "g1KDbaL72ZYii33nVgDeC7vAn7sJcHeddNHM7RWJ65sYTEwPN",
"amount": "2000"
}
]
}
},
{
"id": "0000455693-45609-000003",
"blockNumber": 455693,
"remark": "Merci ",
"author": {
"id": "g1PaLtaGwJKWfmU2usSdpP9Ztu9Yy3F1CB1XXvxmL1oGgLzYB",
"identity": null
},
"transfersByCommentId": {
"nodes": [
{
"fromId": "g1PaLtaGwJKWfmU2usSdpP9Ztu9Yy3F1CB1XXvxmL1oGgLzYB",
"toId": "g1Md8DVDZN3SsgaC4ywoQXbRAdsWrFgfbt1XhdoBfMidndTVp",
"amount": "11100"
}
]
}
},
{
"id": "0000499132-ee3d5-000006",
"blockNumber": 499132,
"remark": "Merci ",
"author": {
"id": "g1N1RtPV8RMvN3CtmSwMHyzBkL7EG7DgtPmbda8ebPnRG79tS",
"identity": {
"index": 12625,
"name": "AgnesJs",
"isMember": true
}
},
"transfersByCommentId": {
"nodes": [
{
"fromId": "g1N1RtPV8RMvN3CtmSwMHyzBkL7EG7DgtPmbda8ebPnRG79tS",
"toId": "g1QNHCsN8tEKwX9sutP1zRJM8CrfL64Ycq2z9A44bE9ajUe58",
"amount": "10000"
}
]
}
}
]
}
}
}
+17 -2
View File
@@ -480,9 +480,24 @@ async fn e1_and_e2_go_to_level_4_once_and_an_open_change_stays_open_voted_at_4()
let at = if *code == "E1" { 4 } else { 3 };
assert_eq!(d["proposal"]["votedAtInertia"], at, "{group} {code}");
}
// And votable: in Monnaie libre, judged at level 4.
// And votable: in Monnaie libre, judged at level 4. A vote there is confirmed on the
// chain (tests/ballots.rs); here, one such vote is written as the confirmation writes it.
let (_, _, e1_ml) = &proposals[1];
c.vote(e1_ml, "for").await;
sqlx::query(
"INSERT INTO identities (id, kind, display_name, g1_member, admin, created_at)
VALUES ('idty:9001', 'g1', 'Alice', 1, 0, '2026-10-30T00:00:00Z')",
)
.execute(&pool)
.await
.expect("identity");
sqlx::query(
"INSERT INTO votes (proposal_id, identity_id, choice, revision, cast_at, source, block, event_index)
VALUES (?, 'idty:9001', 'for', 1, '2026-10-30T00:00:00Z', 'chain', 100, 1)",
)
.bind(e1_ml)
.execute(&pool)
.await
.expect("vote");
let d = c.get(&format!("/api/proposals/{e1_ml}")).await;
let ratio = d["proposal"]["evaluation"]["requiredRatio"].as_f64().expect("ratio");
let p = d["participation"]["ratio"].as_f64().expect("p");
+6 -4
View File
@@ -137,9 +137,11 @@ async fn a_group_one_does_not_open_shows_nothing_of_what_is_coming() {
#[tokio::test]
async fn decisions_one_line_per_doc_with_the_modules_left_to_vote() {
// In the test group, which votes on the server (Monnaie libre votes on the chain:
// tests/ballots.rs counts « sans ton vote » there).
let mut c = Client::new().await;
c.login("Chiara", false).await;
let d = decisions(&mut c, "monnaie-libre").await;
let d = decisions(&mut c, "essai").await;
assert_eq!(d["canVote"], true);
let docs = d["docs"].as_array().expect("docs");
assert_eq!(docs.len(), 1);
@@ -149,16 +151,16 @@ async fn decisions_one_line_per_doc_with_the_modules_left_to_vote() {
assert_eq!(docs[0]["toVote"], 33);
assert_eq!(docs[0]["withoutMyVote"], 33);
let doc = the_doc(&mut c, "monnaie-libre").await;
let doc = the_doc(&mut c, "essai").await;
c.vote(&version(&doc, "E3", "0"), "for").await;
c.vote(&version(&doc, "E3", "1"), "against").await;
c.vote(&version(&doc, "I1", "inForce"), "for").await;
let d = decisions(&mut c, "monnaie-libre").await;
let d = decisions(&mut c, "essai").await;
assert_eq!((d["docs"][0]["toVote"].as_u64(), d["docs"][0]["withoutMyVote"].as_u64()), (Some(33), Some(31)));
// Someone else has not voted yet.
let mut yvv = c.other();
yvv.login("Yvv", false).await;
assert_eq!(decisions(&mut yvv, "monnaie-libre").await["docs"][0]["withoutMyVote"], 33);
assert_eq!(decisions(&mut yvv, "essai").await["docs"][0]["withoutMyVote"], 33);
}
#[tokio::test]
+65 -16
View File
@@ -14,7 +14,9 @@
//! - everything is open to a member except access management, which belongs
//! to trustinWallet's administrators;
//! - where the electorate is the web of trust (Monnaie libre), only its members
//! vote (2026-10-21, independent review: one person, one vote).
//! vote (2026-10-21, independent review: one person, one vote) — through the
//! Ğ1 chain, one transaction per vote, for the identity that signs it, member
//! when the vote is confirmed (2026-10-30, ADR 0008).
use serde::{Deserialize, Serialize};
@@ -101,16 +103,46 @@ pub fn opens(identity: &Identity, group: &Group, granted: bool) -> bool {
is_member(identity, group, granted)
}
/// Can this identity vote in the group? Where the electorate is the Ğ1 web of
/// trust (policy `G1Members`), only its members vote: the threshold is computed
/// on the size of the web, and an invitation identity next to a Ğ1 identity
/// would give one person two votes. Development identities stand in for members
/// (local only: the dev login is never enabled in production).
/// How a group votes.
#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "lowercase")]
pub enum VoteWay {
/// On the server: one vote per identity of the group (managed groups).
Server,
/// On the Ğ1 chain: one transaction per vote, signed by the person's wallet
/// (ADR 0008). The vote counts for the Ğ1 identity of the signer.
Chain,
}
/// Where the electorate is the Ğ1 web of trust (policy `G1Members`), votes go
/// through the chain (Yvv, 2026-10-30: « une transaction par vote »): one
/// person, one voice, proven by the identity that signs — not by the way one
/// signed in here. Elsewhere, on the server.
pub fn vote_way(group: &Group) -> VoteWay {
match group.policy {
GroupPolicy::G1Members => VoteWay::Chain,
GroupPolicy::Managed => VoteWay::Server,
}
}
/// Can this identity vote on the server in the group? In a managed group, every
/// member. Where the vote goes through the chain, nobody: an invitation identity
/// next to a Ğ1 identity would give one person two voices.
pub fn votes_in(identity: &Identity, group: &Group, granted: bool) -> bool {
opens(identity, group, granted)
&& (group.policy != GroupPolicy::G1Members
|| identity.kind == IdentityKind::Dev
|| (identity.kind == IdentityKind::G1 && identity.g1_member))
opens(identity, group, granted) && vote_way(group) == VoteWay::Server
}
/// Can this identity ask for a ballot to sign with a wallet? Whoever opens a
/// group that votes on the chain: who votes is decided by the chain itself, at
/// confirmation (`chain_vote_counts`).
pub fn asks_ballot(identity: &Identity, group: &Group, granted: bool) -> bool {
opens(identity, group, granted) && vote_way(group) == VoteWay::Chain
}
/// Does a vote signed on the chain count? Only for an identity that is a member
/// of the web of trust when the vote is confirmed (the electorate W is the web).
pub fn chain_vote_counts(member: bool) -> bool {
member
}
/// Access management stays with trustinWallet's administrators.
@@ -191,15 +223,32 @@ mod tests {
}
#[test]
fn only_wot_members_vote_where_the_electorate_is_the_web_of_trust() {
fn where_the_electorate_is_the_web_of_trust_the_vote_goes_through_the_chain() {
let ml = group("monnaie-libre");
assert!(votes_in(&who(IdentityKind::G1, true, false), &ml, false));
assert_eq!(vote_way(&ml), VoteWay::Chain);
assert_eq!(vote_way(&group("essai")), VoteWay::Server);
// Nobody votes on the server there, whatever the way one signed in.
for kind in [IdentityKind::G1, IdentityKind::Invite, IdentityKind::Dev] {
assert!(!votes_in(&who(kind, true, false), &ml, true), "{kind:?}");
}
// Whoever opens the group may ask for a ballot to sign with a wallet...
let invited = who(IdentityKind::Invite, false, true);
assert!(opens(&invited, &ml, true), "an invitation may open Monnaie libre to read and propose");
assert!(!votes_in(&invited, &ml, true), "but its votes would not be web-of-trust votes");
assert!(!votes_in(&who(IdentityKind::G1, false, false), &ml, true), "a former member reads, does not vote");
assert!(votes_in(&invited, &group("essai"), true), "managed groups: every member votes");
assert!(!votes_in(&invited, &group("essai"), false));
assert!(asks_ballot(&invited, &ml, true));
assert!(asks_ballot(&who(IdentityKind::G1, true, false), &ml, false));
assert!(!asks_ballot(&invited, &ml, false), "not without opening the group");
// ...and the vote counts only for a member of the web of trust, read on the chain.
assert!(chain_vote_counts(true));
assert!(!chain_vote_counts(false), "a former member, or an account without identity, does not vote");
}
#[test]
fn in_a_managed_group_every_member_votes_on_the_server() {
let essai = group("essai");
let invited = who(IdentityKind::Invite, false, false);
assert!(votes_in(&invited, &essai, true), "managed groups: every member votes");
assert!(!votes_in(&invited, &essai, false));
assert!(!asks_ballot(&invited, &essai, true), "no chain there: the test group stays easy to try");
}
#[test]