Fusion du lot B : le vote déplié sous l'article, le panier, le vote par transaction Ğ1 (bulletin scellé)

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Yvv
2026-10-31 00:15:46 +01:00
co-authored by Claude Opus 5.5
59 changed files with 7228 additions and 370 deletions
+21 -10
View File
@@ -22,7 +22,7 @@ développer.
- **Interface** : SvelteKit 2 / Svelte 5 (runes), adapter-static SPA, vite 7, vitest,
Playwright ; npm. Polices Literata (textes) + Nunito (interface), fontsource.
- **Ports** : dev interface **3002** (strict, proxy `/api` → 8002) · serveur **8002** ·
serveur jetable des e2e **8092**.
serveur jetable des e2e **8092** (sa chaîne Ğ1 simulée prend un port local éphémère).
- **Déploiement** : une image (`docker/app.Dockerfile`), Woodpecker `ci.open.us.org` dépôt
LPK/decision → sonic (fabio). `.woodpecker/app.yml` = le produit sur
**decision.librodrome.org** ; `.woodpecker/maquette.yml` = la maquette sur
@@ -35,27 +35,36 @@ développer.
crates/
ld-engine/ # PUR (zéro I/O) : threshold (formule inertielle), inertia (cinq crans, médiane,
# référence, échelle), edit (coquille vs fond, diff), assist (découpage, similarité,
# longueur), compose (adoption, aperçus)
trustin-access/ # règles d'accès de trustinWallet : sees / opens / manages_access, groupes par défaut
# longueur), compose (adoption, aperçus), ballot (bulletin scellé ld1:, le dernier
# fait foi, jugement d'une transaction), address (SS58, préfixe g1)
trustin-access/ # règles d'accès de trustinWallet : sees / opens / manages_access, vote_way (serveur ou
# chaîne), groupes par défaut
ld-server/
migrations/ # schéma SQLite — additif seulement
src/ # lib.rs (routes, csrf, run) · config.rs (LD_*) · auth.rs (connect OAuth2, invitations,
# dev, bootstrap) · team.rs (liens d'équipe) · docs.rs (lecture, aperçus) · proposals.rs (écrire, voter, signaler,
# traduire) · adoption.rs (balayage, verrou) · participation.rs (p̃, une fois par
# jour) · publish.rs (file → git) · wot.rs (W sur la chaîne) · seed.rs ·
# seed_amend.rs / inertia_amend.rs (amendements gardés) · web.rs (sert web/build)
tests/api.rs # scénarios bout en bout
# seed_amend.rs / inertia_amend.rs (amendements gardés) · ballots.rs (vote groupé,
# bulletins de la chaîne Ğ1, confirmation, « ton vote ») · indexer.rs (txComments)
# · web.rs (sert web/build)
tests/ # api.rs (scénarios bout en bout) · ballots.rs (vote par la chaîne, indexeur simulé)
seeds/ # acte-engagement-certification.json (trilingue, 33 articles, I1/E1/E2 en vigueur ;
# inertie 3, E1/E2 à 4)
# upcoming/<groupe>.json : les « À venir » des outils d'un groupe (fr, en, es), lus au démarrage
web/src/
lib/i18n/ # fr.ts SOURCE (types Key) · en.ts · es.ts — parité testée
lib/components/ # Sidebar, DocPanel, VoteView, ProposeView, UnitBlock, DeepList, Seal, Logo, Pillars…
lib/components/ # Sidebar, DocPanel, VoteFold (le vote déplié sous l'article), BallotPanel (panier),
# ChainSign (signature, Ğecko), VersionDetails, ProposeView, UnitBlock, DeepList, Seal…
lib/wallet/ # injected.ts (portefeuille injectedWeb3, polkadot-api chargé à la signature) · gecko.ts
lib/basket.ts # PUR : sections, tout pour / tout contre, versions en concurrence
routes/ # / (accueil + choix du groupe) · connexion · invitation · equipe · g/[group] (piliers, en cours) ·
# g/[group]/docs · g/[group]/docs/[doc] (lecteur,
# panneau dans l'URL ?onglet=&article=) · g/[group]/decisions · g/[group]/mandats ·
# vote déplié ?vote=, panneau ?onglet=panier|propositions|proposer|details|reglages)
# · g/[group]/decisions · g/[group]/mandats ·
# g/[group]/protocoles (à venir) · acces (trustinWallet) ; /groupes, /decisions… redirigent
web/e2e/ # Playwright C1–C20 + M1 ; serve.sh = serveur jetable /tmp/ld-e2e sur :8092
web/e2e/ # Playwright C1–C24 + M1 ; serve.sh = serveur jetable /tmp/ld-e2e sur :8092 et
# chain-mock.mjs (nœud et indexeur Ğ1 simulés) ; faux portefeuille dans helpers.ts
docker/ # app.Dockerfile · app-compose.yml (sonic)
.woodpecker/ # app.yml · maquette.yml
docs/ # chantiers/ · adr/ · dev/BLUEPRINT-V2.md (doctrine de la maquette) · recherche/
@@ -76,7 +85,7 @@ maquette/ # v2 intacte (frontend Nuxt, backend FastAPI, docker, docs-v
```bash
export PATH=$HOME/.cargo/bin:$PATH
cargo fmt --all --check && cargo clippy --workspace --all-targets -- -D warnings && cargo test --workspace
cd web && npm run check && npm test && npm run build && npm run e2e # e2e : 21 contrôles
cd web && npm run check && npm test && npm run build && npm run e2e # e2e : 25 contrôles
LD_DEV_LOGIN=1 cargo run -p ld-server # :8002, sert web/build
cd web && npm run dev # :3002 à chaud
@@ -93,7 +102,9 @@ docker build -f docker/app.Dockerfile --target production . # rejoue toute la
- **Invariants** : une seule voix ne fait jamais passer un texte (C = 50 pour Monnaie libre) ;
une coquille ne demande pas de vote, un changement de fond si ; un signalement de
contradiction bloque ; l'inertie de l'inertie hérite du cran de l'article (cinq crans, ADR 0007) ;
le groupe d'essai ne publie jamais.
le groupe d'essai ne publie jamais ; dans Monnaie libre, un vote est une transaction Ğ1 et le
dernier fait foi dans l'ordre de la chaîne (ADR 0008) — **jamais de transaction réelle en test**
(chaîne simulée ; un essai réel est un acte « paye » d'Yvv).
- **Écritures** : en-tête `x-requested-with: libredecision` obligatoire (CSRF) ; cookie
`ld_session`. Les tests d'API passent par `Client` (`tests/api.rs`).
- **Pièges vus** : `pkill -f target/debug/ld-server` tue son propre shell → `pkill -x ld-server` ;
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;
+664
View File
@@ -0,0 +1,664 @@
//! 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? })))
}
/// `DELETE /api/ballots/{id}` — the wallet refused to sign, or could not pay:
/// nothing left for the chain, so the ballot is let go at once rather than shown
/// as waiting for a day. Only a pending ballot, only by the session that asked.
pub async fn drop(
State(state): State<AppState>,
User(user): User,
Path(id): Path<String>,
) -> AppResult<Json<serde_json::Value>> {
let row = load(&state, &id).await?;
if row.requested_by != user.id {
return Err(AppError::NotFound);
}
sqlx::query(
"UPDATE ballots SET status = 'expired', reason = 'dropped', settled_at = ? WHERE id = ? AND status = 'pending'",
)
.bind(util::now())
.bind(&id)
.execute(&state.db)
.await?;
let 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).
/// A ballot the wallet never signed (dropped) says nothing.
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 = ? AND IFNULL(b.reason, '') <> 'dropped'
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).delete(ballots::drop))
.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;
+818
View File
@@ -0,0 +1,818 @@
//! 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_ballot_the_wallet_refused_to_sign_is_let_go_at_once() {
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 first = c.ballot(&doc, &[(&i1, Some("against"))], Some(ALICE)).await;
let b = c.ballot(&doc, &[(&i1, Some("for"))], Some(ALICE)).await;
assert_eq!(find(&c.doc("monnaie-libre").await, &i1)["myBallot"]["id"], b["id"]);
// Another session may not let it go.
let mut other = c.other();
other.login("Yvv").await;
let (s, _) = other.call("DELETE", &format!("/api/ballots/{}", b["id"].as_str().expect("id")), None).await;
assert_eq!(s, StatusCode::NOT_FOUND);
// Its own session does: no longer waiting, and it says nothing under the article.
let dropped = c.ok("DELETE", &format!("/api/ballots/{}", b["id"].as_str().expect("id")), None).await;
assert_eq!(
(dropped["ballot"]["status"].as_str(), dropped["ballot"]["reason"].as_str()),
(Some("expired"), Some("dropped"))
);
assert_eq!(find(&c.doc("monnaie-libre").await, &i1)["myBallot"]["id"], first["id"]);
// A confirmed ballot is never let go.
indexer.pay(100, first["comment"].as_str().expect("c"), alice());
ballots::sweep(&state).await.expect("sweep");
let kept = c.ok("DELETE", &format!("/api/ballots/{}", first["id"].as_str().expect("id")), None).await;
assert_eq!(kept["ballot"]["status"], "confirmed");
let now = c.doc("monnaie-libre").await;
assert_eq!((find(&now, &i1)["myVote"].as_str(), find(&now, &i1)["myBallot"].is_null()), (Some("against"), true));
}
#[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
@@ -516,9 +516,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]
+3
View File
@@ -18,6 +18,9 @@ services:
LD_BOOTSTRAP_ADMIN: "1"
LD_PUBLISH_REMOTE: ${LD_PUBLISH_REMOTE:-gitea@git.open.us.org:yvv/decision-depot-textes-reference.git}
LD_PUBLISH_BRANCH: main
# The vote through the Ğ1 chain (ADR 0008): LD_VOTE_ACCOUNT, the Ğ1 address of the vote
# account, once Yvv has created it (LD_VOTE_AMOUNT in cents, 10 by default). Without it,
# the vote through the chain is off and the interface says so; managed groups vote as before.
RUST_LOG: info,sqlx=warn,tower_http=warn
volumes:
- data:/data
@@ -40,6 +40,9 @@ Duniter peut se loger et tout faire — sauf la gestion des accès », qui revie
- **Une personne, une voix** : là où l'électorat est la toile de confiance (Monnaie libre),
seuls ses membres votent ; une identité d'invitation y lit et propose. Un nouveau lien ne
vise jamais une identité Ğ1 (un administrateur ouvrirait une session à sa place).
*Depuis le 2026-10-30 ([0008](0008-vote-par-transaction.md)) : dans Monnaie libre, chaque vote
est une transaction de la chaîne Ğ1 ; la voix est celle de l'identité du compte qui signe,
quelle que soit la session.*
- Premier administrateur : un lien d'invitation écrit au premier démarrage dans le volume de
données (0600), jamais dans les journaux.
+187
View File
@@ -0,0 +1,187 @@
# 0008 — Le vote par transaction sur la chaîne Ğ1
> Complète [0002](0002-vote-permanent.md) (vote permanent) et [0006](0006-acces-connect-trustinwallet.md)
> (accès). Décision d'Yvv du 2026-10-30 (revue, L9) ; le format du bulletin, la voie du
> portefeuille et les réglages sont les choix par défaut d'orch (Q5, Q6, Q7), isolés pour être
> changés sans toucher au reste.
**Contexte.** Jusqu'ici, un vote était une ligne écrite par le serveur pour la session connectée.
Dans Monnaie libre, l'électorat W est la toile de confiance, mais la connexion Ğ1
(connect.monnaie-libre.fr) n'est pas active et toutes les identités viennent d'invitations : en
pratique, personne ne pouvait voter. 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 » ; « 0,1 Ğ1 » ; « qu'un
geste dans le vote » ; « Ğ1Companion ou Ğecko, à terme trustinWallet » ; « Je peux voter tout
d'un coup, ou une section entière, ou un article avec son titre, ou séparément. »
## Décision
### Qui vote où
- **Où l'électorat est la toile** (politique `g1-members` : Monnaie libre), chaque vote est **une
transaction de la chaîne Ğ1**, signée par le portefeuille de la personne. Le serveur n'y écrit
plus de vote direct (`409 chain_only`). La voix est celle de **l'identité Ğ1 du compte qui
signe**, quelle que soit la session qui a préparé le vote (invitation, plus tard connexion Ğ1).
- **Dans un groupe géré** (essai, Axiom…), le vote reste écrit par le serveur, sans chaîne ni
portefeuille, comme avant (`409 server_vote` si on y demande un bulletin). Le geste groupé y
marche aussi : une seule écriture.
- La règle est dans trustinWallet : `trustin_access::vote_way` (`chain` / `server`),
`votes_in`, `asks_ballot`, `chain_vote_counts`.
### Le bulletin scellé (Q5) — `crates/ld-engine/src/ballot.rs`
Le serveur scelle un **bulletin en attente** : le document, le compte qui signera (quand la page
le connaît), les lignes (version, révision votée, choix), un sel de 128 bits tiré au hasard. Sa
forme canonique, en ASCII, les lignes triées par version :
```
ld1
doc <id du document>
author <adresse Ğ1 du compte qui signe, ou ->
line <id de la version> <révision> <for|against|none>
…
salt <32 chiffres hexadécimaux>
```
`H = blake2b-256(forme canonique)` ; la **remarque** de la transaction est `ld1:` suivi des 64
chiffres hexadécimaux de H, 68 octets ASCII. Exemple (vecteur des tests, calculé à part) :
```
ld1
doc d0c
author -
line p1 3 for
line p2 1 against
salt 00112233445566778899aabbccddeeff
```
→ remarque `ld1:20448d496744292981c8586856afb58a86e37da2470f764b4e6ed84eade71340`, que
l'indexeur retrouve par `blake2b-256(remarque)` =
`0xe765221a75716dde863a7b53df3a97acf6db38a2118edd525d54c8a2979b7bc3`.
Le sel empêche de deviner le choix (trois choix par ligne se devineraient tous). L'adresse liée
empêche qu'un autre compte, qui recopierait la remarque, prenne le vote (voir les limites).
### La transaction (Q6) — `web/src/lib/wallet/`
```
utility.batchAll([
balances.transferKeepAlive(LD_VOTE_ACCOUNT, LD_VOTE_AMOUNT),
system.remarkWithEvent("ld1:" + H),
])
```
Un seul geste, atomique : le virement et la remarque passent ensemble ou pas du tout. La page la
fait signer par l'extension de portefeuille du navigateur (interface standard `injectedWeb3` /
`signPayload` : duniter-connect aujourd'hui, Ğ1Companion ou trustinWallet le jour où elles
l'offrent), avec **polkadot-api chargé au seul moment de signer**, et l'envoie à un nœud Ğ1
(`LD_VOTE_NODES`). Sans portefeuille dans le navigateur : **Ğecko**, par un lien et un QR code
`g1://<compte>?amount=0.1&comment=ld1:<H>` (un virement commenté, ce que Ğecko sait faire) ;
ce bulletin-là n'est lié à aucun compte, et la page attend.
### La confirmation — `crates/ld-server/src/ballots.rs`, `indexer.rs`
Le serveur lit l'indexeur (duniter-squid, `LD_INDEXERS`) :
```graphql
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 } } } }
}
```
`hash` = `0x` + blake2b-256 des octets de la remarque. Il garde la première occurrence, dans
l'ordre de la chaîne, qui ne vient pas d'un autre compte que celui lié, puis exige :
1. la remarque exacte ;
2. dans la même transaction, un virement **du signataire vers le compte des votes**, d'au moins
le montant ;
3. une identité Ğ1 au signataire (`no_identity` sinon) ;
4. que cette identité soit **membre maintenant** (`not_member` sinon) — par la règle de
trustinWallet.
Le vote compte alors pour `idty:<index>` (créée si besoin : sorte g1, membre), ligne par ligne,
avec le bloc et le rang de l'évènement ; le balayage d'adoption tourne aussitôt. La session qui a
demandé le bulletin voit ces votes comme « ton vote » — et seulement ceux de ses bulletins : une
autre session ne voit jamais ce qu'a voté la même identité ailleurs.
Le serveur relit l'indexeur pour chaque bulletin en attente toutes les 10 secondes (tâche de
fond) et quand la page demande (au plus une lecture toutes les 3 secondes par bulletin). Un
bulletin non vu en **24 heures** est lâché. Cas d'erreur, chacun testé : indexeur muet (le
bulletin reste en attente), signataire non membre ou sans identité (refusé, et dit), mauvais
montant ou mauvais destinataire (refusé), déjà confirmé (rien ne change), révision changée entre
temps (le vote porte sur la révision votée : « à reconfirmer »).
### Le dernier fait foi
Par version et par identité, **dans l'ordre de la chaîne** : le bloc, puis le rang de
l'évènement (lu dans l'identifiant de l'évènement). Un bulletin confirmé tard ne remplace jamais
un bulletin plus récent sur la chaîne. « Retirer mon vote » est un bulletin dont la ligne vaut
`none`. Les transactions d'un même compte gardent l'ordre de ses envois (le nonce) ; le désordre
ne vient que de l'ordre de lecture de l'indexeur, et la règle l'absorbe.
Un bulletin que le portefeuille n'a pas signé (refus, fonds insuffisants, compte absent) est lâché
aussitôt par la page (`DELETE /api/ballots/{id}` : `expired`, raison `dropped`), pour ne pas
rester « en attente » une journée. Jamais un bulletin confirmé.
### Le vote groupé
Un panier : article par article ; « tout pour » / « tout contre » pour une section ou tout le
document — jamais pour un article dont plusieurs versions sont en concurrence : l'interface le
montre, on choisit sa version. Puis **un geste** : une écriture (groupe géré), ou **une
transaction** (chaîne) pour toutes les lignes, au même prix qu'une seule (0,1 Ğ1 par transaction,
pas par ligne ; 200 lignes au plus, 20 bulletins en attente par session).
### Ce que voit la chaîne
Qu'un compte (et son identité) a envoyé 0,1 Ğ1 au compte des votes avec une remarque `ld1:…`, à
tel bloc : **qui a voté et quand, jamais quoi** — ni le document, ni le nombre de lignes, ni les
choix. Le solde du compte des votes est public : il dit combien de votes ont été envoyés.
### Ce que sait le serveur
Les bulletins, leurs lignes, leurs sels, la session qui les a demandés et l'identité qui les a
signés : il sait qui a voté quoi, comme pour un vote direct, et ne montre que les totaux. Il ne
peut pas inventer un vote d'une identité (il faudrait sa signature sur la chaîne) ; il pourrait en
taire un. Chaque vote compté se vérifie contre la chaîne en recalculant H depuis les lignes et le
sel — ce que personne d'autre ne peut faire tant que les sels restent au serveur.
### Les réglages (Q7) — `crates/ld-server/src/config.rs`
- `LD_VOTE_ACCOUNT` : le compte des votes, une adresse Ğ1 (toute forme SS58, ramenée au préfixe
g1). **Sans lui, le vote par la chaîne est fermé** et l'interface le dit avec des mots simples ;
les groupes gérés votent comme avant. Une adresse illisible est journalisée et laisse le vote
fermé. Aucune adresse n'est écrite en dur.
- `LD_VOTE_AMOUNT` : en centimes de Ğ1, 10 par défaut (0,1 Ğ1), au moins 1.
- `LD_VOTE_NODES` : les nœuds où la page envoie la transaction (par défaut
`wss://rpc.duniter.org`, `wss://g1.axiom-team.fr/ws`).
- `LD_VOTE_TEST_NODE` : tests seulement, et seulement avec la connexion de développement : la page
envoie ce que le faux portefeuille a signé à un faux nœud, sans polkadot-api.
## Les données
Migration `0006`, additive : `ballots` (le bulletin, son sel, H, `0x`+blake2b-256 de la remarque,
le compte et le montant du moment, l'état `pending | confirmed | refused | expired`, la raison,
l'identité qui a voté, le signataire, le bloc, le rang, l'évènement, ce qui est entré en vigueur,
les dates) et `ballot_lines` (version, révision, choix). `votes` gagne `source` (`server` par
défaut : tous les votes d'avant restent tels quels), `block`, `event_index`, `ballot_id`.
## Coût
- Voter coûte 0,1 Ğ1 par geste, plus les frais de la chaîne (non mesurés ici) ; le portefeuille
doit garder au moins 1 Ğ1 (dépôt existentiel, `transferKeepAlive`). Le compte des votes
accumule ces Ğ1 : à qui il appartient, et ce qu'on en fait, reste à décider par Yvv.
- Le serveur **fait confiance à l'indexeur** pour la remarque, le virement, l'identité et
l'appartenance à la toile : un indexeur menteur ferait compter ou refuser un vote. Plusieurs
indexeurs se configurent ; lire les évènements sur un nœud serait l'étape suivante.
- Le droit de vote se vérifie **au moment de la confirmation sur la chaîne** : un membre sorti
de la toile ensuite garde ses voix.
- Par Ğecko, le bulletin n'est lié à aucun compte : un autre compte qui recopierait la remarque
et la ferait inclure avant (en payant 0,1 Ğ1) prendrait ce bulletin — ses lignes compteraient
pour lui. Par l'extension, le bulletin est lié au compte et la copie est ignorée.
- Un paiement Ğecko fait après 24 heures ne compte pas (il est perdu pour le vote).
- polkadot-api : environ 180 Ko (66 Ko compressés) chargés au moment de signer seulement ;
`node_modules` grossit d'environ 77 Mo (son outillage).
- La chaîne montre la participation de chacun (qui a voté, quand, combien de fois).
+1
View File
@@ -12,3 +12,4 @@ pas une décision : on en écrit une nouvelle qui la remplace, et on le note dan
| [0005](0005-publication-git.md) | Publication par file d'envoi vers un dépôt git | 2026-10-21 |
| [0006](0006-acces-connect-trustinwallet.md) | Accès : connect.monnaie-libre.fr, invitations, trustinWallet | 2026-10-21 |
| [0007](0007-inertie-cinq-crans.md) | Inertie à cinq crans, calée sur la participation du groupe (complète 0002) | 2026-10-30 |
| [0008](0008-vote-par-transaction.md) | Le vote par transaction sur la chaîne Ğ1 : bulletin scellé, confirmation par l'indexeur, le dernier fait foi | 2026-10-30 |
+40 -13
View File
@@ -34,19 +34,32 @@ les applications clientes (Cesium, Ğecko…) peuvent suivre.
il n'y a qu'un seul être humain vivant ; derrière un être humain, il n'y a qu'une seule clé
certifiante. ») et les deux engagements fondamentaux (Unicité du compte, Réciprocité), marqués
*seed a minima · 0 vote*. Au survol (ou toujours visibles sur mobile), trois gestes discrets :
**Le vote** (en cours, inertie, formule, annexes) · **Les propositions** · **Proposer**. Ils
ouvrent un panneau latéral (une feuille sur mobile) dont l'état vit dans l'URL. Depuis
l'en-tête du document et depuis chaque vote, **« Réglages de la formule »** : W, C, B, la
participation habituelle du groupe et les cinq crans (onglet « Réglages » du panneau).
**Le vote** · **Les propositions** · **Proposer**. **Le vote se déplie sous l'article**, façon
accordéon (un seul à la fois, l'état dans l'URL : `?vote=`) : le niveau d'eau, les totaux
pour et contre (jamais qui), le seuil et les voix qui manquent, ton vote et où il en est
(enregistré, inscrit sur la chaîne Ğ1, en attente de la chaîne, à reconfirmer, non compté et
pourquoi), puis l'inertie et la formule. Les autres gestes ouvrent un panneau latéral (une
feuille sur mobile), lui aussi dans l'URL : **Panier**, **Propositions**, **Proposer**,
**Détails** d'une version (coquilles, traductions, signalements, historique) et
**« Réglages de la formule »** (W, C, B, la participation habituelle du groupe et les cinq
crans), atteints depuis l'en-tête du document et depuis chaque vote.
5. **En dessous**, les propositions en cours — les 30 autres articles du texte source, à zéro
vote, inertie 3 / 5 — rangées par section (introduction, fondamental, technique,
conclusion, annexes, formule, inertie, ordonnancement), **une ligne par article** : un
article qui a plusieurs versions (variantes du seed, propositions rivales) montre leur
nombre et la meilleure en tête (niveau d'eau, pour / contre) ; la ligne se déplie sur ses
versions, chacune avec son étiquette, son niveau et ses marques, et chacune ouvre son vote.
Les filtres gardent un article dès qu'une de ses versions passe.
6. **Deux aperçus** : « si les bien cotées passent » et « si tout passe ».
7. **Trois langues** : fr · en · es, pour l'interface comme pour le texte. Le français fait foi ;
versions, chacune avec son étiquette, son niveau et ses marques, et chacune déplie son vote.
Les filtres gardent un article dès qu'une de ses versions passe. Chaque section offre
**« Voter cette section »**.
6. **Le vote groupé** (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 ») : « Voter en lot » ouvre un **panier** — pour,
contre ou retirer, article par article ; « tout pour » / « tout contre » pour une section ou
tout le document, jamais pour un article dont plusieurs versions sont en concurrence (il est
montré, on y choisit sa version) ; puis un seul geste : une écriture dans un groupe géré, une
transaction sur la chaîne Ğ1 dans Monnaie libre. Le panier tient sur un téléphone et reste
le temps de l'onglet.
7. **Deux aperçus** : « si les bien cotées passent » et « si tout passe ».
8. **Trois langues** : fr · en · es, pour l'interface comme pour le texte. Le français fait foi ;
une traduction devenue ancienne (le français a changé depuis) est signalée.
## Les règles
@@ -207,9 +220,18 @@ corriger, signaler) **sauf la gestion des accès**, réservée aux administrateu
Le nom trustinWallet remplace trustWallet (déjà pris). Règles : `crates/trustin-access`.
**Une personne, une voix.** Dans Monnaie libre, dont l'électorat W est la toile de confiance,
**seuls les membres de la toile votent** (connexion Ğ1). Une identité d'invitation y lit,
propose, corrige et signale, mais ne vote pas : sinon la même personne, invitée puis connectée
avec sa clé, voterait deux fois. Dans les groupes gérés (essai, Axiom…), tout membre vote.
**seuls les membres de la toile votent, par la chaîne Ğ1** (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 […] une transaction par vote »). Un vote — un article, une section ou tout le document —
est **une transaction** signée par le portefeuille de la personne (duniter-connect, ou Ğecko par
un lien et un QR code) : 0,1 Ğ1 vers le compte des votes et, dans la même transaction, le sceau
du bulletin (`ld1:` + 64 chiffres hexadécimaux). La voix est celle de **l'identité Ğ1 du compte
qui signe**, membre au moment où le serveur confirme la transaction sur l'indexeur ; quelle que
soit la session (invitation ou connexion Ğ1), la même identité n'a qu'une voix par version, et
**le dernier vote fait foi**, dans l'ordre de la chaîne. La chaîne montre qui a voté et quand,
jamais quoi ; chacun ne voit que les totaux. Sans compte des votes configuré
(`LD_VOTE_ACCOUNT`), le vote par la chaîne est fermé et l'interface le dit. Dans les groupes
gérés (essai, Axiom…), tout membre vote directement, sans portefeuille.
## Le groupe d'essai
@@ -233,8 +255,13 @@ Les raisons longues sont dans `docs/adr/`.
## Limites connues (assumées pour ce premier chantier)
- Le droit de vote se vérifie **au moment du vote** : un membre sorti de la toile, ou dont
l'accès est révoqué, garde les voix déjà exprimées.
- Le droit de vote se vérifie **au moment de la confirmation sur la chaîne** : un membre sorti
de la toile, ou dont l'accès est révoqué, garde les voix déjà confirmées.
- Le serveur fait confiance à l'indexeur pour lire la transaction (remarque, virement, identité,
appartenance à la toile) ; plusieurs indexeurs se configurent (ADR 0008).
- Par Ğecko, le bulletin n'est lié à aucun compte : une copie de son sceau incluse avant lui par
un autre compte le prendrait. Par l'extension, il est lié au compte qui signe.
- Un bulletin non vu sur la chaîne en 24 heures est lâché : un paiement plus tardif ne compte pas.
- Dans un groupe géré (essai, Axiom…), une même personne admise sous une identité d'invitation
**et** sous sa clé Ğ1 aurait deux voix : c'est à l'administration de n'en admettre qu'une.
- Un administrateur peut créer un nouveau lien pour une identité d'invitation existante
+18 -10
View File
@@ -4,32 +4,39 @@ Chaque contrôle existe en deux versions : **automatique** (Playwright, `web/e2e
`npm run e2e`) et **humaine** (Chiara, sur la production ou en local). Une remarque de Chiara
sur un contrôle vaut échec du contrôle tant qu'elle n'est pas traitée.
Où tester : les contrôles qui écrivent (C5, C6, C7) se font dans le **groupe d'essai**
(caché, plancher C = 1, jamais publié) — jamais sur le texte de la communauté.
Où tester : les contrôles qui écrivent (C5, C6, C7, C21, C22) se font dans le **groupe
d'essai** (caché, plancher C = 1, jamais publié) — jamais sur le texte de la communauté. Les
votes par la chaîne Ğ1 (C11, C17, C23) ne se jouent, pour l'automate, que sur une **chaîne
simulée** (`web/e2e/chain-mock.mjs` : un faux nœud, un faux indexeur, un faux portefeuille) ;
pour une personne, ils coûtent 0,1 Ğ1 réels : c'est un acte « paye » d'Yvv.
| # | Contrôle | Geste humain | Attendu |
|---|---|---|---|
| C1 | L'accueil, sans session | ouvrir `/` sans être connectée | « Rendre possible la décision collective », puis le leitmotiv en trois lignes : « contexte et cadrage », « processus et modalités », « boîte à outils » ; « Se connecter avec sa clé Ğ1 » et sa note ; pas de piliers ni de groupes ; le menu à gauche avec les quatre outils, trois éteints |
| C2 | L'accueil, avec session | se connecter : on arrive sur `/` | le titre, le leitmotiv, puis directement le choix du groupe : Monnaie libre en grand, Axiom, Librodrome, Groupe local Vallée Drôme Crestois ; **pas** l'Atelier du canal ; `/groupes` et « Mes groupes » (menu) ramènent ici |
| C3 | Le texte en vigueur | Monnaie libre → Docs de référence → Acte d'engagement Certification | trois articles seulement (Raison d'être, Unicité du compte, Réciprocité), chacun « seed a minima · 0 vote » ; en dessous, « Propositions en cours » (30 lignes, une par article) |
| C4 | Gestes contextuels | survoler la Raison d'être → « Le vote » | un panneau : seuil 50 voix pour, il en manque 50 ; « La formule du seuil » se déplie : « M — majorité à pleine participation : 55 % — inertie 3 / 5 · moyenne », et le lien « Réglages de la formule » ; l'URL garde le panneau ; Échap le ferme |
| C5 | Entrée en vigueur | groupe d'essai : à deux, voter « Pour » sur « Les deux garanties réciproques » | au second vote, « … est entré en vigueur » ; le texte en vigueur compte quatre articles |
| C6 | Coquille sans vote | groupe d'essai : Unicité du compte → « Le vote » → « Corriger une coquille », remplacer les apostrophes droites par des apostrophes typographiques (« Je m’engage sur l’honneur ») | « Coquille corrigée, sans toucher aux votes. » ; un changement de sens est refusé : « Ce changement touche au sens : propose-le comme modification. » |
| C4 | Gestes contextuels | survoler la Raison d'être → « Le vote » | le vote se **déplie sous l'article**, sans fenêtre : seuil 50 voix pour, il en manque 50 ; « La formule du seuil » se déplie : « M — majorité à pleine participation : 55 % — inertie 3 / 5 · moyenne », et le lien « Réglages de la formule » ; l'URL garde le vote déplié (`?vote=`) ; « Le vote » de nouveau le replie |
| C5 | Entrée en vigueur | groupe d'essai : à deux, déplier le vote de « Les deux garanties réciproques », « Pour » | au second vote, « … est entré en vigueur » ; le texte en vigueur compte quatre articles, le vote reste déplié sous le nouveau texte (2 pour · 0 contre) |
| C6 | Coquille sans vote | groupe d'essai : Unicité du compte → « Le vote » → « Coquilles, traductions, signalements, historique » (onglet « Détails ») → « Corriger une coquille », remplacer les apostrophes droites par des apostrophes typographiques (« Je m’engage sur l’honneur ») | « Coquille corrigée, sans toucher aux votes. » ; un changement de sens est refusé : « Ce changement touche au sens : propose-le comme modification. » |
| C7 | Une décision par proposition | groupe d'essai : « Proposer un nouvel article », deux paragraphes « Je m'engage à… » | l'assistant : « Ce texte semble porter 2 décisions » ; « Découper en 2 brouillons » ; « Tes brouillons » apparaît |
| C8 | Trois langues | choisir EN puis ES pour le texte | « Certification Commitment Act », « Acta de compromiso de Certificación » ; l'interface suit |
| C9 | Les aperçus | « Si tout passe », puis « Si les bien cotées passent » | 33 articles avec la mention d'aperçu, puis 3 |
| C10 | trustinWallet | ouvrir `/acces` sans être administratrice | « La gestion des accès est réservée aux administrateurs de trustinWallet. » (côté admin : créer un lien d'invitation) |
| C11 | Une personne, une voix | avec un lien d'invitation, ouvrir le vote d'un article de Monnaie libre | « Ici, seuls les membres de la toile de confiance Ğ1 votent… » à la place des boutons ; dans le groupe d'essai, les boutons sont là |
| C11 | Une personne, une voix | avec un lien d'invitation et un portefeuille dont le compte n'est pas membre de la toile (Bob, chaîne simulée), voter « Pour » sur un article de Monnaie libre | le vote dit que la voix est celle de l'identité Ğ1 du portefeuille qui signe ; après signature : « Ton vote n'a pas été compté : le portefeuille qui a signé n'est pas celui d'un membre de la toile de confiance Ğ1. », et sous l'article « Ton dernier vote n'a pas été compté… », 0 pour ; dans le groupe d'essai, la même personne vote directement, sans portefeuille |
| C12 | Lien d'équipe | administrateur : `/acces` → « Liens d'équipe », cocher le groupe d'essai, créer ; l'ouvrir dans deux navigateurs, sous deux noms | chacun arrive sur ses groupes sous son nom ; dans `/acces`, « 2 / 30 personnes » et deux identités « venue par » le lien ; un nom déjà pris est refusé, même autrement écrit (« luna » pour « Luna ») ; sur un navigateur où quelqu'un est connecté, « Ce n'est pas moi » le déconnecte ; « Révoquer » ferme le lien |
| M1 | Mobile | téléphone (ou fenêtre étroite) | le logo dans la barre du haut ; l'accueil montre les groupes ; menu en tiroir (le nombre à côté de « Docs de référence », les quatre pastilles d'ambiance), les trois gestes visibles sans survol, le panneau s'ouvre en feuille ; « Réglages de la formule » depuis le vote : l'onglet « Réglages » visible, le tableau des cinq crans tient dans la feuille |
| M1 | Mobile | téléphone (ou fenêtre étroite) | le logo dans la barre du haut ; l'accueil montre les groupes ; menu en tiroir (le nombre à côté de « Docs de référence », les quatre pastilles d'ambiance) ; **au clavier, les pastilles** : un seul arrêt de Tab, les flèches gauche / droite passent d'une ambiance à l'autre et la choisissent ; l'anneau de focus, en pointillé, se lit sur la page (contraste 3:1 au moins, dans les quatre ambiances) et ne se confond pas avec l'anneau plein de l'ambiance choisie ; les trois gestes visibles sans survol ; le vote se déplie sous l'article ; « Réglages de la formule » depuis le vote : le panneau en feuille, l'onglet « Réglages » visible, le tableau des cinq crans tient dans la feuille ; « Voter en lot » : le panier tient dans la feuille, son bouton « Signer et envoyer » reste en vue |
| C13 | Logo et ambiances | ouvrir `/` (appareil en clair, puis en sombre) ; choisir chaque pastille d'ambiance ; recharger | le logo « Clé de voûte » (signe, *libre*, **Decision**) droit, sans tampon ; le sceau 井 en bas à droite ; première visite : pierre (clair) ou nuit (sombre) ; les quatre pastilles (Pierre, Calcaire, Nuit, Ardoise) changent l'ambiance, qui reste après rechargement ; favicon, icône Apple et manifeste servis |
| C14 | La page d'un groupe | Monnaie libre | le nom ; le pavé des quatre piliers (Docs de référence « ouvert », les trois autres « à venir ») ; « En cours » : l'Acte d'engagement Certification, qui ouvre son lecteur |
| C15 | Le menu | survoler les outils ; ouvrir Docs, puis Mandats ; revenir à l'accueil | « 1 » à côté de « Docs de référence » ; aucun outil surligné sur la page du groupe, seul celui où l'on est ensuite ; au survol, seul Docs réagit ; Mandats s'ouvre quand même (ses « À venir ») ; hors d'un groupe, les outils mènent au dernier groupe ouvert (ou à l'accueil s'il n'y en a pas) |
| C16 | En cours, à venir | Monnaie libre → Docs, Mandats, Protocoles ; les anciennes adresses `/decisions`, `/mandats`, `/protocoles` | Docs : « En cours » (Acte d'engagement Certification) et « À venir » (Acte d'engagement forgerons) ; Mandats : Administration forum Duniter, Administration forum Monnaie libre ; Protocoles : Runtime upgrade — décision on-chain ; les « À venir » sont éteints, sans lien ; ils suivent la langue ; les anciennes adresses mènent aux outils du groupe courant |
| C17 | Décisions | Monnaie libre → Décisions ; voter sur une version d'un article, recharger | une ligne par doc : « Acte d'engagement Certification · 33 modules à voter · dont 33 sans ton vote », puis « dont 32 » ; la ligne ouvre le lecteur |
| C17 | Décisions | Monnaie libre → Décisions ; voter (par la chaîne : bulletin, transaction, confirmation) sur une version d'un article, recharger | une ligne par doc : « Acte d'engagement Certification · 33 modules à voter · dont 33 sans ton vote », puis « dont 32 » ; la ligne ouvre le lecteur |
| C18 | La Raison d'être | Monnaie libre → Acte d'engagement Certification, puis EN, puis ES | en tête du texte en vigueur, « Raison d'être » : la première phrase en italique, puis deux paragraphes ; « Purpose », « Razón de ser » |
| C19 | Les versions d'un article | Monnaie libre → « Propositions en cours » → « Connaissance suffisante » (au clavier : Tab jusqu'à la ligne, Entrée) | une seule ligne, « 2 versions » ; dépliée : « variante 1.0.0 » et « variante 0.4.0-draft », chacune avec son niveau et ce qui la distingue ; un clic sur une version ouvre son vote ; un nouveau clic sur la ligne la replie |
| C20 | Réglages de la formule et cinq crans | Monnaie libre → Acte d'engagement : « Réglages de la formule » (en-tête) ; puis Unicité du compte → « Le vote » → « Réglages de la formule » ; dans le groupe d'essai, Réciprocité → « Le vote » → « Proposer un changement d'inertie » → 5 → « Soumettre au vote » | W 5 745 (toile de confiance Ğ1), C 50, B 0,1 ; « 100 votants sur 5 745 (1,74 %), deux fois C : la participation de référence… (0 pour l'instant) » ; le tableau des cinq crans : très basse, basse, moyenne, haute, très haute ; M 50, 50, 55, 60, 66 % ; part exigée 60, 67, 75, 83, 90 % ; voix pour à 100 votants 80, 84, 88, 92, 95 ; depuis le vote : « Inertie · 4 / 5 · haute », la ligne du cran 4 marquée « cet article », 50 voix à 0 votant ; le sélecteur montre les cinq crans, 4 « actuelle » et grisé ; la proposition s'ouvre : « inertie vers 5 / 5 · très haute », votée à « inertie 4 / 5 · haute » |
| C19 | Les versions d'un article | Monnaie libre → « Propositions en cours » → « Connaissance suffisante » (au clavier : Tab jusqu'à la ligne, Entrée) | une seule ligne, « 2 versions » ; dépliée : « variante 1.0.0 » et « variante 0.4.0-draft », chacune avec son niveau et ce qui la distingue ; un clic sur une version déplie son vote sous elle ; un nouveau clic sur la ligne la replie |
| C20 | Réglages de la formule et cinq crans | Monnaie libre → Acte d'engagement : « Réglages de la formule » (en-tête) ; puis Unicité du compte → « Le vote » → « Réglages de la formule » ; dans le groupe d'essai, Réciprocité → « Le vote » → « Proposer un changement d'inertie » → 5 → « Soumettre au vote » | W 5 745 (toile de confiance Ğ1), C 50, B 0,1 ; « 100 votants sur 5 745 (1,74 %), deux fois C : la participation de référence… (0 pour l'instant) » ; le tableau des cinq crans : très basse, basse, moyenne, haute, très haute ; M 50, 50, 55, 60, 66 % ; part exigée 60, 67, 75, 83, 90 % ; voix pour à 100 votants 80, 84, 88, 92, 95 ; depuis le vote déplié : « Inertie · 4 / 5 · haute », la ligne du cran 4 marquée « cet article », 50 voix à 0 votant ; le sélecteur montre les cinq crans, 4 « actuelle » et grisé ; le vote de la proposition se déplie : « inertie vers 5 / 5 · très haute », votée à « inertie 4 / 5 · haute » |
| C21 | Le vote déplié | groupe d'essai : Unicité du compte → « Le vote », puis « Pour » ; une autre personne ouvre le même vote | sous l'article : le niveau d'eau, « 0 pour · 0 contre », « Seuil : … voix pour », « Il manque … », « Tu n'as pas voté sur cette version. » ; après « Pour » : « Ton vote : pour · enregistré », « 1 pour · 0 contre », le bouton enfoncé, « Ton vote ne se montre à personne : seuls les totaux sont visibles. » ; l'autre personne voit 1 pour, jamais qui ; « Le vote » replie ; « Retirer mon vote » remet 0 |
| C22 | Le vote groupé | groupe d'essai : « Propositions en cours » → « Engagements techniques » → « Voter cette section » ; « Tout pour » ; dans « Connaissance suffisante », « Pour » sur la variante 1.0.0 ; recharger ; « Voter (n) » | le panier s'ouvre sur la section ; « Tout pour » remplit les articles à une version, pas « Connaissance suffisante » ni « Rencontre physique… » (« Plusieurs versions : choisis la tienne. », et le compte des articles laissés) ; le panier reste après rechargement (« Voter en lot · n ») ; un seul envoi : « n votes enregistrés. », le panier vide, la variante choisie à 1 pour |
| C23 | Le vote par la chaîne Ğ1 | Monnaie libre, un portefeuille dans le navigateur : « Voter en lot », « Pour » sur Unicité du compte et Réciprocité, « Signer et envoyer (2) » ; puis sous Réciprocité, « Contre » (l'indexeur en retard) puis « Retirer mon vote » ; puis sans portefeuille : « Pour » → « Passer par Ğecko » | une seule signature pour les deux : 0,1 Ğ1 vers le compte des votes et une remarque `ld1:` + 64 chiffres, rien du vote en clair ; « Ton vote compte : il est inscrit sur la chaîne Ğ1. », « Compté pour l'identité Ğ1 « Alice ». » ; sous l'article « Ton vote : pour · inscrit sur la chaîne Ğ1 », 1 pour ; « En attente de la chaîne Ğ1 : contre. » ; le retrait, plus récent sur la chaîne, fait foi même quand le « contre » est confirmé après lui (0 pour · 0 contre) ; Ğecko : un QR code et le lien `g1://<compte>?amount=0.1&comment=ld1:…`, puis la confirmation dès que la chaîne la montre |
| C24 | Sans compte des votes | Monnaie libre sur un serveur sans `LD_VOTE_ACCOUNT`, puis le groupe d'essai | pas de « Voter en lot » ni « Voter cette section » ; sous l'article : « Le vote par la chaîne Ğ1 n'est pas encore ouvert : le compte qui reçoit les votes reste à créer. Tu peux lire, proposer et signaler une contradiction. », pas de bouton, les totaux et le seuil restent ; dans le groupe d'essai, le panier et les boutons sont là |
## Au-delà de la liste
@@ -58,3 +65,4 @@ page, la langue et la taille de l'écran. Les retours se traitent dans trustinWa
| 2026-10-30 | automate | local, lot R (Raison d'être, versions d'un article) | C1–C14, M1 | 15 / 15 verts |
| 2026-10-30 | automate | local, lot A (entrée, logo, ambiances, menu, listes, Décisions) | C1–C17, M1 | 18 / 18 verts |
| 2026-10-30 | automate | local, lot C (inertie à cinq crans, réglages de la formule) | C1–C20, M1 | 21 / 21 verts |
| 2026-10-31 | automate | local, lot B (le vote déplié, le panier, le vote par la chaîne Ğ1 simulée) | C1–C24, M1 | 25 / 25 verts |
+120
View File
@@ -0,0 +1,120 @@
// A simulated Ğ1 chain for the end-to-end tests (ADR 0008): never a real node,
// never a real transaction. Two doors:
//
// - `/rpc`, the « node »: the page, in test mode, sends what the fake wallet
// signed (JSON as hex, see `testExtrinsic` in src/lib/wallet/injected.ts);
// each one is included in a new block, as the transfer and the remark of the
// real `utility.batchAll` would be;
// - `/v1/graphql`, the « indexer »: answers the server's query
// `txComments(condition: {hash})` in the shape of indexer.duniter.org.
//
// And `/control/*` for the tests: `hide` (what is included next stays unseen by
// the indexer, as an indexer late on the chain), `show` (stop hiding), `release`
// (the indexer catches up), `silent` (the indexer stops answering) and `state`.
//
// Listens on an ephemeral port on 127.0.0.1, written to the file given as argument.
import { createServer } from 'node:http';
import { writeFileSync } from 'node:fs';
import { blake2b } from '@noble/hashes/blake2.js';
// Substrate's well-known development keys as Ğ1 addresses, with the identity each
// one has on this simulated chain. Bob has an identity, but is not a member.
const IDENTITIES = {
g1Pn1geRZnHcVdWdKkoYwQrAARDTXK1D7H3zuXj7GNayT1thu: { index: 9001, name: 'Alice', isMember: true },
g1NCrQaFReZcbUW8pKsazfWFCKNSdrTomnDraUMybJc4DFF5g: { index: 9002, name: 'Bob', isMember: false },
g1NFWUkPTyZWE77YUrfBKfAhV9W81qmgdD17v1NGhb1EjYFqi: { index: 9003, name: 'Charlie', isMember: true },
g1QBoGEqZuxL77pF4rMa25f1cH6HAxwgBq66zZu76JPqsaVgj: { index: 9005, name: 'Eve', isMember: true },
};
const state = { block: 3_400_000, comments: [], hiding: false, silent: false, submitted: 0 };
const hex = (bytes) => Buffer.from(bytes).toString('hex');
const remarkHash = (remark) => `0x${hex(blake2b(Buffer.from(remark, 'utf8'), { dkLen: 32 }))}`;
function send(res, status, body, cors = false) {
const headers = { 'content-type': 'application/json' };
if (cors) Object.assign(headers, { 'access-control-allow-origin': '*', 'access-control-allow-headers': 'content-type' });
res.writeHead(status, headers);
res.end(body === undefined ? '' : JSON.stringify(body));
}
async function read(req) {
let raw = '';
for await (const chunk of req) raw += chunk;
try {
return JSON.parse(raw || '{}');
} catch {
return {};
}
}
/** One signed extrinsic of the test mode: a new block, its transfer and its remark. */
function include(fields) {
state.block += 1;
state.submitted += 1;
const comment = {
id: `${String(state.block).padStart(10, '0')}-${hex(Buffer.from(String(state.block))).slice(-5)}-000002`,
blockNumber: state.block,
remark: fields.remark,
author: { id: fields.address, identity: IDENTITIES[fields.address] ?? null },
transfersByCommentId: { nodes: [{ fromId: fields.address, toId: fields.dest, amount: String(fields.amount) }] },
hidden: state.hiding,
};
state.comments.push(comment);
return comment;
}
const server = createServer(async (req, res) => {
const url = new URL(req.url ?? '/', 'http://mock');
if (req.method === 'OPTIONS') return send(res, 204, undefined, true);
if (url.pathname === '/rpc' && req.method === 'POST') {
const body = await read(req);
if (body.method !== 'author_submitExtrinsic') return send(res, 200, { jsonrpc: '2.0', id: body.id, error: { code: -32601, message: 'Method not found' } }, true);
let fields;
try {
fields = JSON.parse(Buffer.from(String(body.params?.[0] ?? '').slice(2), 'hex').toString('utf8'));
} catch {
fields = null;
}
if (!fields?.signature || !fields.remark || !fields.address) {
return send(res, 200, { jsonrpc: '2.0', id: body.id, error: { code: 1010, message: 'Invalid Transaction' } }, true);
}
const c = include(fields);
return send(res, 200, { jsonrpc: '2.0', id: body.id, result: `0x${hex(blake2b(Buffer.from(c.id), { dkLen: 32 }))}` }, true);
}
if (url.pathname === '/v1/graphql' && req.method === 'POST') {
const body = await read(req);
const hash = body.variables?.hash;
// Only the query of the ballots; the others (size of the web of trust) get nothing, as from a dead indexer.
if (state.silent || !hash || !String(body.query ?? '').includes('txComments')) return send(res, 502, { error: 'unavailable' });
const nodes = state.comments
.filter((c) => !c.hidden && remarkHash(c.remark) === hash)
.sort((a, b) => a.blockNumber - b.blockNumber)
.map(({ hidden: _, ...c }) => c);
return send(res, 200, { data: { txComments: { nodes } } });
}
if (url.pathname.startsWith('/control/') && req.method === 'POST') {
const what = url.pathname.slice('/control/'.length);
if (what === 'hide') state.hiding = true;
else if (what === 'show') state.hiding = false;
else if (what === 'release') {
state.hiding = false;
for (const c of state.comments) c.hidden = false;
} else if (what === 'silent') state.silent = url.searchParams.get('on') !== '0';
else if (what !== 'state') return send(res, 404, { error: 'unknown' }, true);
return send(res, 200, { block: state.block, submitted: state.submitted, hidden: state.comments.filter((c) => c.hidden).length }, true);
}
send(res, 404, { error: 'not_found' }, true);
});
server.listen(0, '127.0.0.1', () => {
const { port } = server.address();
const file = process.argv[2];
if (file) writeFileSync(file, String(port));
console.log(`chain mock on 127.0.0.1:${port}`);
});
+15 -7
View File
@@ -1,5 +1,5 @@
import { expect, test, type Page } from '@playwright/test';
import { DOC, person, signIn } from './helpers';
import { chain, CHARLIE, DOC, person, signIn } from './helpers';
const ML = '/g/monnaie-libre';
const path = (p: string) => (u: URL) => u.pathname === p;
@@ -145,15 +145,23 @@ test('C17 — Décisions : une ligne par doc, les modules à voter, dont sans to
await expect(rows.first()).toContainText('33 modules à voter');
await expect(rows.first()).toContainText('dont 33 sans ton vote');
// A vote on one version of E3 (it has a variant): one module fewer without her vote. « Contre »
// counts as much, and leaves the previews of C9 unchanged (the server is shared).
const group = await (await page.request.get('/api/groups/monnaie-libre')).json();
const doc = await (await page.request.get(`/api/docs/${group.docs[0].id}`)).json();
const e3 = doc.units.find((u: { code: string }) => u.code === 'E3');
const r = await page.request.post(`/api/proposals/${e3.proposals[1].id}/vote`, {
data: { choice: 'against' },
// counts as much, and leaves the previews of C9 unchanged (the server is shared). In Monnaie
// libre the vote goes through the Ğ1 chain: the ballot, then what her wallet (Charlie's
// account) would send, on the simulated chain.
const { doc, node } = await chain(page);
const e3 = doc.units.find((u) => u.code === 'E3');
const r = await page.request.post(`/api/docs/${doc.doc.id}/ballots`, {
data: { lines: [{ proposalId: e3?.proposals[1]?.id, choice: 'against' }], author: CHARLIE.address },
headers: { 'x-requested-with': 'libredecision' },
});
expect(r.ok()).toBe(true);
const { ballot } = await r.json();
const fields = { address: CHARLIE.address, dest: ballot.account, amount: String(ballot.amount), remark: ballot.comment, signature: '0x01' };
const extrinsic = `0x${Buffer.from(JSON.stringify(fields)).toString('hex')}`;
await page.request.post(node, { data: { jsonrpc: '2.0', id: 1, method: 'author_submitExtrinsic', params: [extrinsic] } });
await expect
.poll(async () => (await (await page.request.get(`/api/ballots/${ballot.id}`)).json()).ballot.status, { timeout: 15_000 })
.toBe('confirmed');
await page.reload();
await expect(rows.first()).toContainText('33 modules à voter');
await expect(rows.first()).toContainText('dont 32 sans ton vote');
+65
View File
@@ -3,6 +3,13 @@ import type { Browser, Page } from '@playwright/test';
export const DOC = '/g/monnaie-libre/docs/acte-engagement-certification';
export const TEST_DOC = '/g/essai/docs/acte-engagement-certification';
/** The vote account of the e2e server (serve.sh): Dave's development key. */
export const VOTE_ACCOUNT = 'g1L5EYvvSBaYuQCPW78bHxYcvHfmWAqHYe3Gsrp9SV4kWZaEM';
/** Wallets of the simulated chain (chain-mock.mjs): Alice and Charlie are members, Bob is not. */
export const ALICE = { address: 'g1Pn1geRZnHcVdWdKkoYwQrAARDTXK1D7H3zuXj7GNayT1thu', name: 'Alice' };
export const BOB = { address: 'g1NCrQaFReZcbUW8pKsazfWFCKNSdrTomnDraUMybJc4DFF5g', name: 'Bob' };
export const CHARLIE = { address: 'g1NFWUkPTyZWE77YUrfBKfAhV9W81qmgdD17v1NGhb1EjYFqi', name: 'Charlie' };
export async function signIn(page: Page, name: string, admin = false) {
const r = await page.request.post('/api/auth/dev', {
data: { name, admin },
@@ -17,3 +24,61 @@ export async function person(browser: Browser, name: string, admin = false): Pro
await signIn(page, name, admin);
return page;
}
/**
* A fake wallet extension, as duniter-connect offers itself to pages
* (`window.injectedWeb3`): it lists `accounts` and signs whatever it is given,
* recording it in `window.__ldSigned`; `window.__ldRefuse = true` makes it refuse.
* It signs nothing real: the page, in test mode, sends to the simulated node.
*/
export async function wallet(page: Page, accounts: { address: string; name: string }[]) {
await page.addInitScript((list) => {
const w = window as unknown as Record<string, unknown> & { __ldSigned: unknown[]; __ldRefuse?: boolean };
w.__ldSigned = [];
w.injectedWeb3 = {
'duniter-connect': {
version: 'e2e',
enable: async () => ({
accounts: { get: async () => list, subscribe: () => () => undefined },
signer: {
signPayload: async (payload: Record<string, unknown>) => {
if (w.__ldRefuse) throw new Error('Cancelled');
w.__ldSigned.push(payload);
return { id: w.__ldSigned.length, signature: `0x${'01'.repeat(64)}` };
},
},
}),
},
};
}, accounts);
}
/** What the fake wallet signed, read back (the test extrinsic is JSON as hex). */
export async function signed(page: Page): Promise<{ address: string; dest: string; amount: string; remark: string }[]> {
return page.evaluate(() =>
((window as unknown as { __ldSigned: { method: string }[] }).__ldSigned ?? []).map((p) => {
const bytes = (p.method.slice(2).match(/../g) ?? []).map((b) => parseInt(b, 16));
return JSON.parse(new TextDecoder().decode(Uint8Array.from(bytes)));
}),
);
}
/** A screenshot for the visual check, when `LD_SHOTS` names a directory (never in the loop). */
export async function shot(page: Page, name: string) {
const dir = process.env.LD_SHOTS;
if (dir) await page.screenshot({ path: `${dir}/${name}.png` });
}
/** The control door of the simulated chain, found where the page finds its node. */
export async function chain(page: Page, docPath = DOC) {
const [, , group] = docPath.split('/');
const g = await (await page.request.get(`/api/groups/${group}`)).json();
const d = await (await page.request.get(`/api/docs/${g.docs[0].id}`)).json();
const node: string = d.vote.chain.testNode;
const base = node.replace(/\/rpc$/, '');
return {
doc: d as { doc: { id: string }; units: { code: string; inForce: { id: string } | null; proposals: { id: string }[] }[] },
node,
control: async (what: 'hide' | 'show' | 'release' | 'state' | 'silent') => (await page.request.post(`${base}/control/${what}`)).json(),
};
}
+85 -9
View File
@@ -1,7 +1,31 @@
import { expect, test } from '@playwright/test';
import { DOC, signIn } from './helpers';
import { expect, test, type Locator } from '@playwright/test';
import { DOC, shot, signIn } from './helpers';
test('M1 — mobile : logo dans la barre, menu en tiroir, actions visibles, panneau en feuille, réglages lisibles', async ({ page }) => {
/** WCAG contrast ratio of two CSS colours `rgb(r, g, b)`. */
function contrast(a: string, b: string): number {
const lum = (c: string) => {
const [r, g, bl] = (c.match(/[\d.]+/g) ?? []).slice(0, 3).map((x) => {
const v = Number(x) / 255;
return v <= 0.03928 ? v / 12.92 : ((v + 0.055) / 1.055) ** 2.4;
});
return 0.2126 * (r ?? 0) + 0.7152 * (g ?? 0) + 0.0722 * (bl ?? 0);
};
const [hi, lo] = [lum(a), lum(b)].sort((x, y) => y - x);
return ((hi ?? 0) + 0.05) / ((lo ?? 0) + 0.05);
}
/** The colour the ring of a swatch is drawn on: the first opaque background around it. */
async function backdrop(el: Locator): Promise<string> {
return el.evaluate((node) => {
for (let n: Element | null = node.parentElement; n; n = n.parentElement) {
const bg = getComputedStyle(n).backgroundColor;
if (bg && bg !== 'transparent' && !/rgba\(.*,\s*0\)$/.test(bg)) return bg;
}
return getComputedStyle(document.body).backgroundColor;
});
}
test('M1 — mobile : logo dans la barre, menu en tiroir, vote déplié, panier, réglages lisibles, pastilles au clavier', async ({ page }) => {
await signIn(page, 'Chiara');
await page.goto('/');
const brand = page.locator('header.topbar a.brand');
@@ -13,15 +37,67 @@ test('M1 — mobile : logo dans la barre, menu en tiroir, actions visibles, pann
const drawer = page.getByRole('dialog', { name: 'Boîte à outils' });
await expect(drawer.locator('.tool', { hasText: 'Protocoles' })).toBeVisible();
await expect(drawer.locator('.tool', { hasText: 'Docs de référence' }).locator('.count')).toHaveText('1');
await expect(drawer.getByRole('radiogroup', { name: 'Ambiance' }).getByRole('radio')).toHaveCount(4);
const moods = drawer.getByRole('radiogroup', { name: 'Ambiance' });
await expect(moods.getByRole('radio')).toHaveCount(4);
// The swatches, by keyboard: one stop for Tab, the arrows move and choose; the focus ring
// reads at 3:1 or more on the page whatever the swatch, and is not the mark of the chosen one.
await expect(moods.locator('[tabindex="0"]')).toHaveCount(1);
const start = (await page.locator('html').getAttribute('data-mood')) ?? '';
await moods.getByRole('radio', { checked: true }).focus();
for (let i = 0; i < 4; i++) {
await page.keyboard.press('ArrowRight');
const now = moods.getByRole('radio', { checked: true });
await expect(now).toBeFocused();
await expect(moods.locator('[tabindex="0"]')).toHaveCount(1);
const ring = await now.evaluate((el) => {
const cs = getComputedStyle(el);
return { style: cs.outlineStyle, color: cs.outlineColor, width: parseFloat(cs.outlineWidth) };
});
expect(ring.style).toBe('dashed');
expect(ring.width).toBeGreaterThanOrEqual(2);
const mood = await page.locator('html').getAttribute('data-mood');
expect(contrast(ring.color, await backdrop(moods)), `${mood}: ${ring.color}`).toBeGreaterThanOrEqual(3);
if (i === 0) await shot(page, '7-pastilles-focus-mobile');
}
await expect(page.locator('html')).toHaveAttribute('data-mood', start);
await page.keyboard.press('ArrowLeft');
await expect(page.locator('html')).not.toHaveAttribute('data-mood', start);
await page.keyboard.press('ArrowRight');
await expect(page.locator('html')).toHaveAttribute('data-mood', start);
// The chosen one, without the focus: its own mark, a ring of ink, never the dashed one.
const chosen = moods.getByRole('radio', { checked: true });
await chosen.evaluate((el) => (el as HTMLElement).blur());
const mark = await chosen.evaluate((el) => ({ outline: getComputedStyle(el).outlineStyle, shadow: getComputedStyle(el).boxShadow }));
expect(mark.outline).toBe('none');
expect(mark.shadow).toContain('0px 0px 0px 2px');
await drawer.getByRole('button', { name: 'Fermer' }).first().click();
// The vote unfolds under the article, on a phone too.
const first = page.locator('section.text article.unit').first();
await first.getByRole('button', { name: 'Le vote' }).click();
await expect(page.locator('aside.panel').getByText('Seuil : 50 voix pour')).toBeVisible();
const fold = first.locator('section.fold');
await expect(fold.getByText('Seuil : 50 voix pour')).toBeVisible();
// The settings of the formula read on a phone: the table of the five levels holds in the sheet.
await page.locator('aside.panel').getByRole('button', { name: 'Réglages de la formule' }).click();
await expect(page.locator('aside.panel').getByRole('tab', { name: 'Réglages' })).toBeInViewport();
await expect(page.locator('aside.panel table tbody tr')).toHaveCount(5);
const fits = await page.locator('aside.panel .scroll').evaluate((el) => el.scrollWidth <= el.clientWidth + 1);
await fold.getByRole('button', { name: 'Réglages de la formule' }).click();
const panel = page.locator('aside.panel');
await expect(panel.getByRole('tab', { name: 'Réglages' })).toBeInViewport();
await expect(panel.locator('table tbody tr')).toHaveCount(5);
const fits = await panel.locator('.scroll').evaluate((el) => el.scrollWidth <= el.clientWidth + 1);
expect(fits).toBe(true);
await panel.getByRole('button', { name: 'Fermer le panneau' }).click();
// The basket holds on a phone: its send button stays in sight, nothing overflows.
await page.getByRole('button', { name: /^Voter en lot/ }).click();
await expect(panel.getByRole('heading', { name: 'Panier de vote' })).toBeVisible();
await panel.getByRole('group', { name: 'Ton choix pour « Unicité du compte »' }).getByRole('button', { name: 'Pour' }).click();
await panel.getByRole('group', { name: 'Ton choix pour « Réciprocité »' }).getByRole('button', { name: 'Contre' }).click();
const send = panel.getByRole('button', { name: 'Signer et envoyer (2)' });
await expect(send).toBeInViewport();
const holds = await panel.locator('.body').evaluate((el) => el.scrollWidth <= el.clientWidth + 1);
expect(holds).toBe(true);
await shot(page, '3-panier-mobile');
// Taken back out of the basket: nothing was sent.
await panel.getByRole('group', { name: 'Tout le document' }).getByRole('button', { name: 'Vider' }).click();
await expect(panel.getByText("Rien dans le panier pour l'instant.")).toBeVisible();
});
+56 -33
View File
@@ -1,5 +1,5 @@
import { expect, test } from '@playwright/test';
import { DOC, person, signIn, TEST_DOC } from './helpers';
import { BOB, DOC, person, signIn, TEST_DOC, wallet } from './helpers';
test('C1 — l’accueil sans session : le titre, le leitmotiv en trois lignes, la connexion, le menu complet', async ({ page }) => {
await page.goto('/');
@@ -49,24 +49,29 @@ test('C3 — le texte en vigueur : raison d’être et deux engagements, seed a
await expect(page.locator('ul.deep button.row')).toHaveCount(30);
});
test('C4 — les actions sont contextuelles, le vote s’ouvre en panneau', async ({ page }) => {
test('C4 — les actions sont contextuelles, le vote se déplie sous l’article', async ({ page }) => {
await signIn(page, 'Chiara');
await page.goto(DOC);
const first = page.locator('section.text article.unit').first();
await first.hover();
await first.getByRole('button', { name: 'Le vote' }).click();
const panel = page.locator('aside.panel');
await expect(panel.getByText('Seuil : 50 voix pour')).toBeVisible();
await expect(panel.getByText('Il manque 50 voix pour.')).toBeVisible();
await panel.getByText('La formule du seuil').click();
await expect(panel.getByText(/W — électorat/)).toBeVisible();
const toggle = first.getByRole('button', { name: 'Le vote' });
await toggle.click();
await expect(toggle).toHaveAttribute('aria-expanded', 'true');
const fold = first.locator('section.fold');
await expect(fold.getByText('Seuil : 50 voix pour')).toBeVisible();
await expect(fold.getByText('Il manque 50 voix pour.')).toBeVisible();
await fold.getByText('La formule du seuil').click();
await expect(fold.getByText(/W — électorat/)).toBeVisible();
// The level of the article sets M and G: the Raison d'être is at 3 / 5.
await expect(panel.getByText('M — majorité à pleine participation : 55 % — inertie 3 / 5 · moyenne')).toBeVisible();
await expect(panel.getByRole('button', { name: 'Réglages de la formule' })).toBeVisible();
// The panel lives in the URL.
await expect(page).toHaveURL(/onglet=vote/);
await page.keyboard.press('Escape');
await expect(panel).toHaveCount(0);
await expect(fold.getByText('M — majorité à pleine participation : 55 % — inertie 3 / 5 · moyenne')).toBeVisible();
await expect(fold.getByRole('button', { name: 'Réglages de la formule' })).toBeVisible();
// No window opens: the vote unfolds in place, lives in the URL, and folds back.
await expect(page.locator('aside.panel')).toHaveCount(0);
await expect(page).toHaveURL(/vote=/);
await toggle.click();
await expect(fold).toHaveCount(0);
await expect(toggle).toHaveAttribute('aria-expanded', 'false');
await expect(page).not.toHaveURL(/vote=/);
});
test('C5 — une proposition remonte en vigueur quand elle atteint son seuil (groupe d’essai)', async ({ browser }) => {
@@ -75,12 +80,13 @@ test('C5 — une proposition remonte en vigueur quand elle atteint son seuil (gr
for (const p of [yvv, chiara]) {
await p.goto(TEST_DOC);
await p.locator('ul.deep button.row', { hasText: 'Les deux garanties réciproques' }).click();
await p.locator('aside.panel').getByRole('button', { name: 'Pour', exact: true }).click();
await p.locator('section.fold').getByRole('button', { name: 'Pour', exact: true }).click();
await expect(p.getByText('Vote enregistré.')).toBeVisible();
}
await expect(chiara.getByText('« Les deux garanties réciproques » est entré en vigueur.')).toBeVisible();
await chiara.keyboard.press('Escape');
await expect(chiara.locator('section.text article.unit')).toHaveCount(4);
// In force now: its vote stays unfolded, under the text.
await expect(chiara.locator('section.text section.fold')).toContainText('2 pour · 0 contre');
});
test('C6 — une coquille se corrige sans vote', async ({ page }) => {
@@ -89,7 +95,10 @@ test('C6 — une coquille se corrige sans vote', async ({ page }) => {
const unicity = page.locator('section.text article.unit', { hasText: 'Unicité du compte' });
await unicity.hover();
await unicity.getByRole('button', { name: 'Le vote' }).click();
// What is not the vote lives in the panel, tab « Détails ».
await unicity.getByRole('button', { name: 'Coquilles, traductions, signalements, historique' }).click();
const panel = page.locator('aside.panel');
await expect(panel.getByRole('tab', { name: 'Détails' })).toHaveAttribute('aria-selected', 'true');
await panel.getByText('Corriger une coquille').click();
const body = panel.getByLabel('Texte', { exact: true });
// Typographic apostrophes: the letters and the signs stay, only their style changes.
@@ -151,7 +160,7 @@ test('C10 — trustinWallet : la gestion des accès est réservée', async ({ br
await expect(yvv.locator('.link code')).toContainText('/invitation#');
});
test('C11 — une personne, une voix : dans Monnaie libre, seuls les membres de la toile votent', async ({ browser }) => {
test('C11 — une personne, une voix : dans Monnaie libre, seuls les membres de la toile votent, par la chaîne', async ({ browser }) => {
const yvv = await person(browser, 'Yvv', true);
const r = await yvv.request.post('/api/access/invitations', {
data: { label: 'Invitée', groups: ['monnaie-libre', 'essai'] },
@@ -160,17 +169,28 @@ test('C11 — une personne, une voix : dans Monnaie libre, seuls les membres de
const { link } = (await r.json()) as { link: string };
const ctx = await browser.newContext({ locale: 'fr-FR' });
const invited = await ctx.newPage();
// Her wallet holds Bob's account: an identity, not a member.
await wallet(invited, [BOB]);
await invited.goto(`/invitation#${link.split('#')[1]}`);
await expect(invited).toHaveURL((u) => u.pathname === '/');
await invited.goto(DOC);
await invited.locator('ul.deep button.row', { hasText: 'Vérification personnelle de la clé' }).click();
const panel = invited.locator('aside.panel');
await expect(panel.getByText('Ici, seuls les membres de la toile de confiance Ğ1 votent', { exact: false })).toBeVisible();
await expect(panel.getByRole('button', { name: 'Pour', exact: true })).toHaveCount(0);
// In the test group, the same person votes.
const fold = invited.locator('section.fold');
// An invitation gives no voice: the voice is that of the Ğ1 identity of the wallet that signs.
await expect(fold.getByText("Ta voix compte pour l'identité Ğ1 du portefeuille qui signe", { exact: false })).toBeVisible();
await fold.getByRole('button', { name: 'Pour', exact: true }).click();
const dialog = invited.getByRole('dialog', { name: 'Voter avec ton portefeuille Ğ1' });
await expect(
dialog.getByText("Ton vote n'a pas été compté : le portefeuille qui a signé n'est pas celui d'un membre de la toile de confiance Ğ1."),
).toBeVisible({ timeout: 20_000 });
await dialog.getByRole('button', { name: 'Fermer' }).click();
await expect(fold.getByText("Ton dernier vote n'a pas été compté", { exact: false })).toBeVisible();
await expect(fold.getByText('0 pour · 0 contre')).toBeVisible();
// In the test group, the same person votes, directly.
await invited.goto(TEST_DOC);
await invited.locator('ul.deep button.row', { hasText: 'Vérification personnelle de la clé' }).click();
await expect(invited.locator('aside.panel').getByRole('button', { name: 'Pour', exact: true })).toBeVisible();
await expect(invited.locator('section.fold').getByRole('button', { name: 'Pour', exact: true })).toBeVisible();
await expect(invited.locator('section.fold').getByText('Ta voix compte', { exact: false })).toHaveCount(0);
});
test('C12 — lien d’équipe : chaque personne qui l’ouvre a sa propre identité', async ({ browser }) => {
@@ -237,10 +257,11 @@ test('C19 — un article, une ligne : ses versions se déplient', async ({ page
await expect(versions).toHaveCount(2);
await expect(versions.nth(0)).toContainText('variante 1.0.0');
await expect(versions.nth(1)).toContainText('variante 0.4.0-draft');
// A version opens its vote, as any proposal.
// A version unfolds its vote below it, as any proposal.
await versions.nth(1).click();
await expect(page.locator('aside.panel').getByText('Seuil : 50 voix pour')).toBeVisible();
await expect(page).toHaveURL(/version=/);
await expect(versions.nth(1)).toHaveAttribute('aria-expanded', 'true');
await expect(page.locator('section.fold').getByText('Seuil : 50 voix pour')).toBeVisible();
await expect(page).toHaveURL(/vote=/);
await e3.click();
await expect(e3).toHaveAttribute('aria-expanded', 'false');
await expect(versions).toHaveCount(0);
@@ -273,8 +294,8 @@ test('C20 — réglages de la formule et cinq crans', async ({ page }) => {
const e1 = page.locator('section.text article.unit', { hasText: 'Unicité du compte' });
await e1.hover();
await e1.getByRole('button', { name: 'Le vote' }).click();
await expect(panel.getByRole('heading', { name: 'Inertie · 4 / 5 · haute' })).toBeVisible();
await panel.getByRole('button', { name: 'Réglages de la formule' }).click();
await expect(e1.getByRole('heading', { name: 'Inertie · 4 / 5 · haute' })).toBeVisible();
await e1.getByRole('button', { name: 'Réglages de la formule' }).click();
await expect(panel.locator('tr.current')).toHaveCount(1);
await expect(panel.locator('tr.current th')).toContainText('4');
await expect(panel.locator('tr.current th')).toContainText('cet article');
@@ -287,15 +308,17 @@ test('C20 — réglages de la formule et cinq crans', async ({ page }) => {
const e2 = page.locator('section.text article.unit', { hasText: 'Réciprocité' });
await e2.hover();
await e2.getByRole('button', { name: 'Le vote' }).click();
await panel.getByText("Proposer un changement d'inertie").click();
const levels = panel.getByRole('radiogroup', { name: 'Nouvelle inertie' }).getByRole('radio');
await e2.getByText("Proposer un changement d'inertie").click();
const levels = e2.getByRole('radiogroup', { name: 'Nouvelle inertie' }).getByRole('radio');
await expect(levels).toHaveCount(5);
await expect(levels.nth(3)).toBeDisabled();
await expect(levels.nth(3)).toContainText('actuelle');
await expect(panel.getByText("Un changement d'inertie se vote lui-même à l'inertie de l'article : 4 / 5 · haute.")).toBeVisible();
await expect(e2.getByText("Un changement d'inertie se vote lui-même à l'inertie de l'article : 4 / 5 · haute.")).toBeVisible();
await levels.nth(4).click();
await expect(levels.nth(4)).toHaveAttribute('aria-checked', 'true');
await panel.getByRole('button', { name: 'Soumettre au vote' }).click();
await expect(panel.getByRole('heading', { name: 'inertie vers 5 / 5 · très haute' })).toBeVisible();
await expect(panel.getByText('inertie 4 / 5 · haute', { exact: true })).toBeVisible();
await e2.getByRole('button', { name: 'Soumettre au vote' }).click();
// Its own vote unfolds, in the list of proposals.
const change = page.getByRole('region', { name: 'Le vote de « inertie vers 5 / 5 · très haute »' });
await expect(change).toBeVisible();
await expect(change.getByText('inertie 4 / 5 · haute', { exact: true })).toBeVisible();
});
+14 -2
View File
@@ -1,11 +1,23 @@
#!/bin/sh
# Throwaway server for the end-to-end tests: fresh database, dev login, built interface.
# Throwaway server for the end-to-end tests: fresh database, dev login, built interface,
# and a simulated Ğ1 chain (e2e/chain-mock.mjs, an ephemeral local port): the vote account
# is a well-known development key, the page sends to the fake node, the server reads the
# fake indexer. Never a real transaction.
set -eu
here=$(cd "$(dirname "$0")/../.." && pwd)
data=/tmp/ld-e2e
rm -rf "$data"
mkdir -p "$data"
cd "$here"
node web/e2e/chain-mock.mjs "$data/chain-mock.port" > "$data/chain-mock.log" 2>&1 &
i=0
while [ ! -s "$data/chain-mock.port" ]; do
i=$((i + 1))
[ "$i" -gt 100 ] && { echo "chain mock did not start" >&2; exit 1; }
sleep 0.1
done
chain="http://127.0.0.1:$(cat "$data/chain-mock.port")"
LD_DATA_DIR="$data" LD_SEED_DIR=seeds LD_WEB_DIR=web/build LD_BIND=127.0.0.1:8092 LD_DEV_LOGIN=1 \
LD_PUBLIC_URL=http://127.0.0.1:8092 LD_RPC_ENDPOINTS=http://127.0.0.1:9 LD_INDEXERS=http://127.0.0.1:9 \
LD_PUBLIC_URL=http://127.0.0.1:8092 LD_RPC_ENDPOINTS=http://127.0.0.1:9 LD_INDEXERS="$chain/v1/graphql" \
LD_VOTE_ACCOUNT=g1L5EYvvSBaYuQCPW78bHxYcvHfmWAqHYe3Gsrp9SV4kWZaEM LD_VOTE_TEST_NODE="$chain/rpc" \
exec ./target/debug/ld-server
+210
View File
@@ -0,0 +1,210 @@
import { expect, test } from '@playwright/test';
import { ALICE, chain, CHARLIE, DOC, person, shot, signed, signIn, TEST_DOC, VOTE_ACCOUNT, wallet } from './helpers';
// Lot B — the vote (Yvv, 2026-10-30): unfolded under the article, grouped in a
// basket, and on the Ğ1 chain where the electorate is the web of trust. The chain
// here is simulated (e2e/chain-mock.mjs): never a real transaction.
test('C21 — le vote déplié sous l’article : ton vote, les totaux, le seuil', async ({ page, browser }) => {
await signIn(page, 'Chiara');
await page.goto(TEST_DOC);
const e1 = page.locator('section.text article.unit', { hasText: 'Unicité du compte' });
await e1.hover();
const toggle = e1.getByRole('button', { name: 'Le vote' });
await toggle.click();
const fold = page.getByRole('region', { name: 'Le vote de « Unicité du compte »' });
await expect(fold).toBeVisible();
await expect(toggle).toHaveAttribute('aria-expanded', 'true');
await expect(toggle).toHaveAttribute('aria-controls', (await fold.getAttribute('id')) ?? '');
// How far the threshold is: the water, the totals, the votes asked and those missing.
await expect(fold.getByRole('img', { name: /Niveau : \d+ % du seuil/ })).toBeVisible();
await expect(fold.getByText(/^Seuil : \d+ voix pour$/)).toBeVisible();
await expect(fold.getByText(/^Il manque \d+ voix pour\.$/)).toBeVisible();
await expect(fold.getByText("Tu n'as pas voté sur cette version.")).toBeVisible();
await expect(fold.getByText('0 pour · 0 contre')).toBeVisible();
// Her vote, at once.
await fold.getByRole('button', { name: 'Pour', exact: true }).click();
await expect(fold.locator('.voted')).toHaveText('Ton vote : pour · enregistré');
await expect(fold.getByText('1 pour · 0 contre')).toBeVisible();
await expect(fold.getByRole('button', { name: 'Pour', exact: true })).toHaveAttribute('aria-pressed', 'true');
await expect(fold.getByText('Ton vote ne se montre à personne : seuls les totaux sont visibles.')).toBeVisible();
await shot(page, '1-vote-deplie');
// Someone else sees the totals, never who voted what.
const yvv = await person(browser, 'Yvv');
await yvv.goto(page.url());
const theirs = yvv.getByRole('region', { name: 'Le vote de « Unicité du compte »' });
await expect(theirs.getByText('1 pour · 0 contre')).toBeVisible();
await expect(theirs.getByText("Tu n'as pas voté sur cette version.")).toBeVisible();
await expect(theirs.getByText('Chiara')).toHaveCount(0);
// Folded back, then her vote withdrawn: the group is left as found.
await toggle.click();
await expect(fold).toHaveCount(0);
await toggle.click();
await fold.getByRole('button', { name: 'Retirer mon vote' }).click();
await expect(fold.getByText('0 pour · 0 contre')).toBeVisible();
});
test('C22 — le vote groupé : une section en un geste, et l’article à deux versions se choisit', async ({ page }) => {
await signIn(page, 'Chiara');
await page.goto(TEST_DOC);
// From the list of proposals: « Voter cette section », on the technical commitments.
await page.locator('ul.deep li.section', { hasText: 'Engagements techniques' }).getByRole('button', { name: 'Voter cette section' }).click();
await expect(page).toHaveURL(/onglet=panier/);
await expect(page).toHaveURL(/section=technique/);
const panel = page.locator('aside.panel');
const tech = panel.getByRole('region', { name: 'Engagements techniques' });
await tech.getByRole('group', { name: 'Engagements techniques' }).getByRole('button', { name: 'Tout pour' }).click();
// E3 has two versions: the grouped gesture leaves it, and says so.
const e3 = tech.locator('li.slot', { hasText: 'Connaissance suffisante' });
await expect(e3.getByText('Plusieurs versions : choisis la tienne.')).toBeVisible();
await expect(e3.getByRole('button', { name: 'Pour', pressed: true })).toHaveCount(0);
await expect(panel.getByRole('status')).toContainText('plusieurs versions');
const filled = await tech.getByRole('button', { name: 'Pour', pressed: true }).count();
expect(filled).toBeGreaterThan(3);
// Her version of E3, chosen by hand.
await e3.getByRole('group', { name: /variante 1\.0\.0/ }).getByRole('button', { name: 'Pour' }).click();
const n = filled + 1;
await expect(panel.getByRole('button', { name: `Voter (${n})` })).toBeEnabled();
await shot(page, '2-panier-bureau');
// The basket stays for the tab: a reload keeps it.
await page.reload();
await expect(page.getByRole('button', { name: `Voter en lot · ${n}` })).toBeVisible();
// One gesture, one write.
const writes: string[] = [];
page.on('request', (r) => {
if (r.method() === 'POST' && r.url().includes('/api/')) writes.push(new URL(r.url()).pathname);
});
await panel.getByRole('button', { name: `Voter (${n})` }).click();
await expect(page.getByText(`${n} votes enregistrés.`)).toBeVisible();
expect(writes.filter((u) => u.endsWith('/votes'))).toHaveLength(1);
expect(writes.filter((u) => u.endsWith('/vote'))).toHaveLength(0);
await expect(panel.getByText("Rien dans le panier pour l'instant.")).toBeVisible();
// Each counted: her version of E3 has its voice, the other none.
await expect(e3.getByRole('group', { name: /variante 1\.0\.0/ }).locator('..')).toContainText('1 pour · 0 contre · ton vote : pour');
await expect(e3.getByRole('group', { name: /variante 0\.4\.0-draft/ }).locator('..')).toContainText('0 pour · 0 contre');
});
test('C23 — le vote par la chaîne Ğ1 : un geste, confirmé par l’indexeur ; le dernier fait foi', async ({ page, browser }) => {
// The chain is waited for: the server reads the indexer every 10 seconds.
test.setTimeout(90_000);
await signIn(page, 'Chiara');
await wallet(page, [ALICE]);
await page.goto(DOC);
const { control } = await chain(page);
await expect(page.getByRole('button', { name: /^Voter en lot/ })).toBeVisible();
await shot(page, '0-entete-voter-en-lot');
// The basket: two articles in force, for, in one transaction.
await page.getByRole('button', { name: /^Voter en lot/ }).click();
const panel = page.locator('aside.panel');
for (const title of ['Unicité du compte', 'Réciprocité']) {
await panel.getByRole('group', { name: `Ton choix pour « ${title} »` }).getByRole('button', { name: 'Pour' }).click();
}
await expect(panel.getByText('Une seule transaction de 0,1 Ğ1, signée par ton portefeuille, pour tous tes choix.')).toBeVisible();
await panel.getByRole('button', { name: 'Signer et envoyer (2)' }).click();
const dialog = page.getByRole('dialog', { name: 'Voter avec ton portefeuille Ğ1' });
await expect(dialog.getByText('2 votes, en une transaction : 0,1 Ğ1 vers le compte des votes, avec ton bulletin scellé.')).toBeVisible();
await expect(dialog.getByText('Ton vote compte : il est inscrit sur la chaîne Ğ1.')).toBeVisible({ timeout: 20_000 });
await expect(dialog.getByText("Compté pour l'identité Ğ1 « Alice ».")).toBeVisible();
// One signature: the amount to the vote account and the sealed ballot — nothing of the vote in clear.
const tx = await signed(page);
expect(tx).toHaveLength(1);
expect(tx[0]).toMatchObject({ address: ALICE.address, dest: VOTE_ACCOUNT, amount: '10' });
expect(tx[0]?.remark).toMatch(/^ld1:[0-9a-f]{64}$/);
await dialog.getByRole('button', { name: 'Fermer' }).click();
await expect(panel.getByText("Rien dans le panier pour l'instant.")).toBeVisible();
await panel.getByRole('button', { name: 'Fermer le panneau' }).click();
// Under the article: her vote, from the chain, and the totals.
const e1 = page.locator('section.text article.unit', { hasText: 'Unicité du compte' });
await e1.hover();
await e1.getByRole('button', { name: 'Le vote' }).click();
const fold1 = page.getByRole('region', { name: 'Le vote de « Unicité du compte »' });
await expect(fold1.locator('.voted')).toHaveText('Ton vote : pour · inscrit sur la chaîne Ğ1');
await expect(fold1.getByText('1 pour · 0 contre')).toBeVisible();
// The last one stands, in the order of the chain: a « contre » included first, but seen late
// by the indexer, never overrides the withdrawal included after it.
const e2 = page.locator('section.text article.unit', { hasText: 'Réciprocité' });
await e2.hover();
await e2.getByRole('button', { name: 'Le vote' }).click();
const fold2 = page.getByRole('region', { name: 'Le vote de « Réciprocité »' });
await control('hide');
await fold2.getByRole('button', { name: 'Contre', exact: true }).click();
await expect(dialog.getByText('La chaîne Ğ1 confirme ton vote', { exact: false })).toBeVisible();
await shot(page, '4-chaine-attente');
await dialog.getByRole('button', { name: 'Fermer' }).click();
await expect(fold2.getByText('En attente de la chaîne Ğ1 : contre.')).toBeVisible();
await control('show');
await fold2.getByRole('button', { name: 'Retirer mon vote' }).click();
await expect(dialog.getByText('Ton vote compte : il est inscrit sur la chaîne Ğ1.')).toBeVisible({ timeout: 20_000 });
await dialog.getByRole('button', { name: 'Fermer' }).click();
await expect(fold2.getByText('0 pour · 0 contre')).toBeVisible();
await control('release');
await expect(fold2.getByText('En attente de la chaîne Ğ1', { exact: false })).toHaveCount(0, { timeout: 30_000 });
await expect(fold2.getByText("Tu n'as pas voté sur cette version.")).toBeVisible();
await expect(fold2.getByText('0 pour · 0 contre')).toBeVisible();
// One vote unfolded at a time (an accordion): back to the first, untouched.
await expect(fold1).toHaveCount(0);
await e1.hover();
await e1.getByRole('button', { name: 'Le vote' }).click();
await expect(fold1.getByText('1 pour · 0 contre')).toBeVisible();
await expect(fold2).toHaveCount(0);
// Without a wallet in the browser: Ğecko, a link and a QR code; the page waits for the chain.
const other = await person(browser, 'Maëlle');
await other.goto(DOC);
const raison = other.locator('section.text article.unit').first();
await raison.hover();
await raison.getByRole('button', { name: 'Le vote' }).click();
await raison.locator('section.fold').getByRole('button', { name: 'Pour', exact: true }).click();
const sheet = other.getByRole('dialog', { name: 'Voter avec ton portefeuille Ğ1' });
await expect(sheet.getByText("Aucun portefeuille n'est branché à ce navigateur", { exact: false })).toBeVisible();
await sheet.getByRole('button', { name: 'Passer par Ğecko' }).click();
await expect(sheet.getByRole('img', { name: 'Code à scanner avec Ğecko' })).toBeVisible();
const link = (await sheet.getByRole('link', { name: 'Ouvrir dans Ğecko' }).getAttribute('href')) ?? '';
expect(link).toMatch(new RegExp(`^g1://${VOTE_ACCOUNT}\\?amount=0\\.1&comment=ld1:[0-9a-f]{64}$`));
await shot(other, '5-gecko-qr');
// Charlie pays it from Ğecko: the simulated node includes it, the page sees it counted.
const { node } = await chain(other);
const remark = link.split('comment=')[1] ?? '';
const fields = { address: CHARLIE.address, dest: VOTE_ACCOUNT, amount: '10', remark, signature: '0x01' };
await other.request.post(node, {
data: { jsonrpc: '2.0', id: 1, method: 'author_submitExtrinsic', params: [`0x${Buffer.from(JSON.stringify(fields)).toString('hex')}`] },
});
await expect(sheet.getByText("Compté pour l'identité Ğ1 « Charlie ».")).toBeVisible({ timeout: 20_000 });
});
test('C24 — sans compte de vote, le vote par la chaîne est fermé et le dit ; le groupe d’essai vote', async ({ page }) => {
await signIn(page, 'Chiara');
// The e2e server has its vote account: the page is shown what a server without one answers
// (the server itself: `without_a_vote_account_the_chain_vote_is_off_and_the_test_group_still_votes`).
await page.route(/\/api\/docs\/[^/?]+$/, async (route) => {
const res = await route.fetch();
const body = await res.json();
if (body.vote?.way === 'chain') {
body.vote = { ...body.vote, open: false, reason: 'chain_off', chain: null };
body.canVote = false;
}
await route.fulfill({ response: res, json: body });
});
await page.goto(DOC);
await expect(page.getByRole('button', { name: /^Voter en lot/ })).toHaveCount(0);
await expect(page.getByRole('button', { name: 'Voter cette section' })).toHaveCount(0);
const first = page.locator('section.text article.unit').first();
await first.hover();
await first.getByRole('button', { name: 'Le vote' }).click();
const fold = first.locator('section.fold');
await expect(fold.getByText("Le vote par la chaîne Ğ1 n'est pas encore ouvert : le compte qui reçoit les votes reste à créer.", { exact: false })).toBeVisible();
await expect(fold.getByRole('button', { name: 'Pour', exact: true })).toHaveCount(0);
// The totals and the threshold stay in sight.
await expect(fold.getByText('Seuil : 50 voix pour')).toBeVisible();
await fold.getByText('Ton vote ne se montre à personne', { exact: false }).scrollIntoViewIfNeeded();
await shot(page, '6-vote-ferme');
// The test group still votes, directly.
await page.goto(TEST_DOC);
await expect(page.getByRole('button', { name: /^Voter en lot/ })).toBeVisible();
const unit = page.locator('section.text article.unit').first();
await unit.hover();
await unit.getByRole('button', { name: 'Le vote' }).click();
await expect(unit.locator('section.fold').getByRole('button', { name: 'Pour', exact: true })).toBeVisible();
});
+1503
View File
File diff suppressed because it is too large Load Diff
+2
View File
@@ -19,9 +19,11 @@
"@sveltejs/kit": "^2.70.0",
"@sveltejs/vite-plugin-svelte": "^6.0.0",
"@types/node": "^22.20.5",
"polkadot-api": "3.3.0",
"svelte": "^5.57.0",
"svelte-check": "^4.0.0",
"typescript": "^5.5.0",
"uqr": "0.1.3",
"vite": "^7.0.0",
"vitest": "^3.2.0"
}
+83
View File
@@ -0,0 +1,83 @@
// The basket of a vote (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 »). One chooses
// article by article; « tout pour » or « tout contre » fills a section or the
// whole document — but never an article whose versions compete: there, one
// chooses one's version. Then one gesture sends it all: one write on the
// server, or one transaction on the Ğ1 chain.
import type { Choice, Proposal, Unit } from './types';
/** A choice in the basket: for, against, or withdrawing one's vote. Absent: nothing. */
export type Picks = Record<string, Choice>;
/** An article as the basket shows it. */
export interface Slot {
unit: Unit;
/** The versions in competition: the text in force, the open texts and removals. */
versions: Proposal[];
/** Open changes of its inertia: another question, never filled by a grouped gesture. */
inertia: Proposal[];
}
export interface Section {
/** The section of the seed (introduction, fondamental…), or null. */
key: string | null;
slots: Slot[];
}
/** The articles of the document with something to vote, by section, in document order. */
export function sections(units: Unit[]): Section[] {
const out: Section[] = [];
for (const unit of units) {
const open = unit.proposals.filter((p) => p.status === 'open');
const versions = [
...(unit.inForce?.status === 'in_force' ? [unit.inForce] : []),
...open.filter((p) => p.kind !== 'inertia'),
];
const inertia = open.filter((p) => p.kind === 'inertia');
if (!versions.length && !inertia.length) continue;
const last = out.at(-1);
const slot = { unit, versions, inertia };
if (last && last.key === unit.section) last.slots.push(slot);
else out.push({ key: unit.section, slots: [slot] });
}
return out;
}
/** « Tout pour » / « tout contre » over some articles: only those with a single version. */
export function fill(slots: Slot[], choice: 'for' | 'against', picks: Picks): Picks {
const next = { ...picks };
for (const s of slots) {
const [only] = s.versions;
if (only && s.versions.length === 1) next[only.id] = choice;
}
return next;
}
/** Articles of several versions where no version is chosen yet: the interface shows them. */
export function waiting(slots: Slot[], picks: Picks): Slot[] {
return slots.filter((s) => s.versions.length > 1 && !s.versions.some((p) => p.id in picks));
}
/** Empties the basket of these articles only. */
export function clearScope(slots: Slot[], picks: Picks): Picks {
const ids = new Set(slots.flatMap((s) => [...s.versions, ...s.inertia].map((p) => p.id)));
return Object.fromEntries(Object.entries(picks).filter(([id]) => !ids.has(id)));
}
/** What is sent: one line per version still in the document; withdrawing is a null choice. */
export function lines(picks: Picks, known: Set<string>): { proposalId: string; choice: 'for' | 'against' | null }[] {
return Object.entries(picks)
.filter(([id]) => known.has(id))
.map(([proposalId, c]) => ({ proposalId, choice: c === 'none' ? null : c }));
}
export function counts(picks: Picks): { for: number; against: number; none: number; total: number } {
const all = Object.values(picks);
return {
for: all.filter((c) => c === 'for').length,
against: all.filter((c) => c === 'against').length,
none: all.filter((c) => c === 'none').length,
total: all.length,
};
}
+306
View File
@@ -0,0 +1,306 @@
<script lang="ts">
// The basket of a vote (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 »): choices
// article by article, « tout pour » / « tout contre » for a section or the whole
// document — never for an article whose versions compete —, then one gesture.
import { onMount } from 'svelte';
import { clearScope, counts, fill, lines, sections, waiting, type Section, type Slot } from '$lib/basket';
import type { DocState } from '$lib/doc.svelte';
import { i18n, t, type Key } from '$lib/i18n/index.svelte';
import { relationOf, titleOf } from '$lib/labels';
import type { BasketState } from '$lib/picks.svelte';
import { toasts } from '$lib/toast.svelte';
import type { Proposal } from '$lib/types';
import { errorText, g1Amount, voteDirect, type Line } from '$lib/vote';
import ChoicePicker from './ChoicePicker.svelte';
let {
doc,
basket,
section = null,
onsign,
onread,
}: {
doc: DocState;
basket: BasketState;
/** A section to bring into sight (« Voter cette section »). */
section?: string | null;
onsign: (lines: Line[]) => void;
/** Unfolds the vote of a version under its article. */
onread: (proposalId: string) => void;
} = $props();
const SECTIONS = ['introduction', 'fondamental', 'technique', 'conclusion', 'annexe', 'formule', 'inertie', 'ordonnancement'];
const sectionLabel = (s: string | null) => (s && SECTIONS.includes(s) ? t(`section.${s}` as Key) : null);
const view = $derived(doc.view);
const access = $derived(view?.vote);
const all = $derived(sections(view?.units ?? []));
const slots = $derived(all.flatMap((s) => s.slots));
const known = $derived(new Set(slots.flatMap((s) => [...s.versions, ...s.inertia].map((p) => p.id))));
const toSend = $derived(lines(basket.picks, known));
const tally = $derived(counts(Object.fromEntries(Object.entries(basket.picks).filter(([id]) => known.has(id)))));
const left = $derived(waiting(slots, basket.picks));
const open = $derived(!!access?.open);
let busy = $state(false);
let error = $state('');
let list: HTMLElement | undefined = $state();
onMount(() => {
if (section) list?.querySelector(`[data-section="${CSS.escape(section)}"]`)?.scrollIntoView({ block: 'start' });
});
function headline(s: Slot): string {
const text = s.versions.find((p) => p.kind === 'text');
return text?.title ?? (s.versions[0] ? titleOf(s.versions[0], s.unit) : s.unit.code);
}
/** What tells a version from the others of its article. */
function labelOf(p: Proposal, s: Slot): string {
if (p.status === 'in_force') return t('basket.inforce');
if (p.kind !== 'text') return titleOf(p, s.unit);
if (p.label) return t('deep.variant', { label: p.label });
if (p.author?.name) return t('panel.by', { name: p.author.name });
return relationOf(p, s.unit) || t('panel.proposal');
}
const scope = (key: string | null): Slot[] => (key === '*' ? slots : (all.find((s: Section) => s.key === key)?.slots ?? []));
async function send() {
error = '';
if (!toSend.length) return;
if (access?.way === 'chain') {
onsign(toSend);
return;
}
busy = true;
try {
const n = await voteDirect(doc, toSend);
basket.clear();
toasts.show(i18n.plural('basket.sent', n));
} catch (e) {
error = errorText(e);
} finally {
busy = false;
}
}
</script>
{#snippet bulk(key: string | null, label: string)}
<div class="bulk" role="group" aria-label={label}>
<button class="btn small" type="button" disabled={!open} onclick={() => basket.set(fill(scope(key), 'for', basket.picks))}>{t('basket.for')}</button>
<button class="btn small" type="button" disabled={!open} onclick={() => basket.set(fill(scope(key), 'against', basket.picks))}>{t('basket.against')}</button>
<button class="btn quiet small" type="button" onclick={() => basket.set(clearScope(scope(key), basket.picks))}>{t('basket.clear')}</button>
</div>
{/snippet}
<section class="basket" aria-labelledby="basket-title">
<h3 id="basket-title">{t('basket.title')}</h3>
<p class="muted">{t('basket.intro')}</p>
{#if access && !access.open}
<p class="closed">{t(access.reason === 'chain_off' ? 'vote.chain.off' : 'vote.members')}</p>
{/if}
<div class="whole">
<span class="eyebrow">{t('basket.doc')}</span>
{@render bulk('*', t('basket.doc'))}
</div>
{#if left.length}
<p class="note" role="status">{i18n.plural('basket.waiting', left.length)}</p>
{/if}
<div class="sections" bind:this={list}>
{#each all as s, i (s.key ?? `none-${i}`)}
{@const label = sectionLabel(s.key)}
<section class="sec" data-section={s.key ?? ''} class:target={!!section && section === s.key} aria-label={label ?? t('basket.doc')}>
{#if label}
<header>
<h4 class="eyebrow">{label}</h4>
{@render bulk(s.key, label)}
</header>
{/if}
<ul>
{#each s.slots as slot (slot.unit.id)}
{@const title = headline(slot)}
{@const several = slot.versions.length > 1}
{@const first = slot.versions[0] ?? slot.inertia[0]}
<li class="slot" class:several class:waiting={left.includes(slot)}>
<button class="title" type="button" onclick={() => first && onread(first.id)}>{title}</button>
{#if several}<p class="versions-note">{t('basket.versions')}</p>{/if}
{#each [...slot.versions, ...slot.inertia] as v (v.id)}
<div class="version">
<span class="about">
<span class="label">{labelOf(v, slot)}</span>
<span class="faint small">
{t('tally.short', { for: v.tally.for, against: v.tally.against })}
{#if v.myVote && !v.myVoteStale} · {t('basket.mine', { choice: t(`vote.choice.${v.myVote}`) })}{/if}
</span>
</span>
<ChoicePicker
value={basket.picks[v.id]}
voted={!!v.myVote}
label={t('basket.pick', { title: several || slot.inertia.includes(v) ? `${title} — ${labelOf(v, slot)}` : title })}
disabled={!open}
onpick={(c) => basket.pick(v.id, c)}
/>
</div>
{/each}
</li>
{/each}
</ul>
</section>
{/each}
</div>
<footer class="send">
<p class="count">
{#if tally.total}{i18n.plural('basket.count', tally.total)}{:else}<span class="faint">{t('basket.empty')}</span>{/if}
</p>
<button class="btn primary" type="button" disabled={!open || !toSend.length || busy} onclick={send}>
{access?.way === 'chain' ? t('basket.send.chain', { n: toSend.length }) : t('basket.send.server', { n: toSend.length })}
</button>
<p class="faint small note-send">
{#if access?.way === 'chain' && access.chain}{t('basket.note.chain', { amount: g1Amount(access.chain.amount) })}{:else}{t('basket.note.server')}{/if}
</p>
{#if error}<p class="warn" role="alert">{error}</p>{/if}
</footer>
</section>
<style>
.basket {
display: grid;
gap: 0.9rem;
}
h3 {
font-family: var(--font-text);
font-weight: 500;
font-size: 1.4rem;
}
.closed {
font-weight: 700;
color: var(--ink-soft);
}
.whole,
.sec header {
display: flex;
flex-wrap: wrap;
align-items: center;
justify-content: space-between;
gap: 0.4rem 0.8rem;
}
.whole {
padding: 0.7rem 0.8rem;
border-radius: var(--r-input);
background: var(--water-wash);
}
.bulk {
display: flex;
flex-wrap: wrap;
gap: 0.3rem;
}
.note {
font-size: 0.86rem;
font-weight: 700;
color: var(--water);
}
.sections {
display: grid;
gap: 1.2rem;
}
.sec {
display: grid;
gap: 0.5rem;
scroll-margin-top: 0.5rem;
border-radius: var(--r-input);
}
.sec.target {
box-shadow: 0 0 0 2px var(--water-wash);
background: var(--water-wash);
padding: 0.5rem;
margin: -0.5rem;
}
.sec h4 {
color: var(--ink-soft);
}
ul {
list-style: none;
margin: 0;
padding: 0;
display: grid;
gap: 0.4rem;
}
.slot {
display: grid;
gap: 0.35rem;
padding: 0.6rem 0.7rem;
border-radius: var(--r-input);
background: var(--surface-2);
}
.slot.several {
background: color-mix(in srgb, var(--surface-2) 70%, var(--water-wash));
}
.slot.waiting {
box-shadow: inset 3px 0 0 var(--water);
}
.title {
justify-self: start;
border: 0;
background: none;
padding: 0;
font-weight: 780;
text-align: left;
color: var(--ink);
}
.title:hover {
color: var(--water);
text-decoration: underline;
text-underline-offset: 3px;
}
.versions-note {
font-size: 0.8rem;
font-weight: 750;
color: var(--water);
}
.version {
display: flex;
flex-wrap: wrap;
align-items: center;
justify-content: space-between;
gap: 0.35rem 0.7rem;
}
.about {
display: grid;
min-width: 0;
}
.label {
font-size: 0.86rem;
font-weight: 700;
}
.small {
font-size: 0.8rem;
}
.send {
position: sticky;
bottom: -1.6rem;
margin: 0 -1.2rem -1.6rem;
padding: 0.8rem 1.2rem 1.2rem;
display: grid;
grid-template-columns: 1fr auto;
align-items: center;
gap: 0.3rem 0.8rem;
background: var(--surface);
box-shadow: 0 -10px 24px -16px rgb(0 0 0 / 0.35);
}
.count {
font-weight: 800;
}
.note-send,
.send .warn {
grid-column: 1 / -1;
}
.warn {
color: var(--ochre);
font-weight: 700;
font-size: 0.9rem;
}
</style>
+421
View File
@@ -0,0 +1,421 @@
<script lang="ts">
// A vote on the Ğ1 chain, in one gesture (Yvv, 2026-10-30): the server seals
// the ballot, the person's wallet signs one transaction — the amount to the
// vote account and the seal of the ballot —, the page waits for the chain to
// show it. Without a wallet in the browser: Ğecko, with a link and a QR code.
import { onDestroy, onMount } from 'svelte';
import { api } from '$lib/api';
import type { DocState } from '$lib/doc.svelte';
import { i18n, t } from '$lib/i18n/index.svelte';
import type { Ballot, ChainSettings } from '$lib/types';
import { announce, errorText, g1Amount, type Line } from '$lib/vote';
import { geckoLink, qrPath } from '$lib/wallet/gecko';
import { failure, nothingSent, wallets, type Failure, type Wallet, type WalletAccount } from '$lib/wallet/injected';
let {
doc,
chain,
lines,
onsent,
onclose,
}: {
doc: DocState;
chain: ChainSettings;
lines: Line[];
/** The ballot is on its way (or confirmed): the basket may be emptied. */
onsent: () => void;
onclose: () => void;
} = $props();
type Step =
| { kind: 'start' }
| { kind: 'none' }
| { kind: 'wallets'; list: Wallet[] }
| { kind: 'accounts'; wallet: Wallet; list: WalletAccount[] }
| { kind: 'opening' }
| { kind: 'signing' }
| { kind: 'waiting' }
| { kind: 'gecko'; link: string; qr: { size: number; d: string } }
| { kind: 'confirmed'; voter: string | null }
| { kind: 'refused'; reason: Ballot['reason'] }
| { kind: 'expired' }
| { kind: 'failed'; text: string };
const REMEMBER = 'ld:wallet';
let dialog: HTMLDialogElement | undefined = $state();
let step = $state<Step>({ kind: 'start' });
let ballot = $state<Ballot | null>(null);
let timer: ReturnType<typeof setInterval> | undefined;
let closed = false;
const amount = $derived(g1Amount(chain.amount));
const short = (a: string) => `${a.slice(0, 6)}…${a.slice(-4)}`;
/** Ğecko is offered where nothing is being signed nor waited for. */
const geckoOffered = $derived(['none', 'wallets', 'accounts', 'failed', 'expired', 'refused'].includes(step.kind));
onMount(() => {
dialog?.showModal();
void start();
});
onDestroy(stop);
function stop() {
clearInterval(timer);
timer = undefined;
}
function close() {
closed = true;
stop();
dialog?.close();
onclose();
}
function remembered(): { id: string; address: string } | null {
try {
return JSON.parse(localStorage.getItem(REMEMBER) ?? 'null');
} catch {
return null;
}
}
function remember(wallet: Wallet | null, account?: WalletAccount) {
try {
if (wallet && account) localStorage.setItem(REMEMBER, JSON.stringify({ id: wallet.id, address: account.address }));
else localStorage.removeItem(REMEMBER);
} catch {
/* private mode */
}
}
async function start() {
stop();
ballot = null;
const list = wallets(chain);
if (!list.length) {
step = { kind: 'none' };
return;
}
const known = remembered();
const usual = list.find((w) => w.id === known?.id);
if (usual) return choose(usual, known?.address);
const [only] = list;
if (only && list.length === 1) return choose(only);
step = { kind: 'wallets', list };
}
async function choose(wallet: Wallet, address?: string) {
step = { kind: 'opening' };
let accounts: WalletAccount[];
try {
accounts = await wallet.accounts();
} catch (e) {
step = { kind: 'failed', text: t(`sign.error.${failure(e)}`) };
return;
}
const usual = accounts.find((a) => a.address === address);
if (usual) return go(wallet, usual);
const [only] = accounts;
if (only && accounts.length === 1) return go(wallet, only);
if (!only) {
step = { kind: 'failed', text: t('sign.error.account') };
return;
}
step = { kind: 'accounts', wallet, list: accounts };
}
async function seal(author: string | null): Promise<Ballot | null> {
try {
const r = await api.post<{ ballot: Ballot }>(`/docs/${doc.view?.doc.id}/ballots`, { lines, author });
return r.ballot;
} catch (e) {
step = { kind: 'failed', text: errorText(e) };
return null;
}
}
async function go(wallet: Wallet, account: WalletAccount) {
remember(wallet, account);
step = { kind: 'opening' };
const b = await seal(account.address);
if (!b) return;
ballot = b;
let sent = false;
try {
step = { kind: 'signing' };
await wallet.send(account, { dest: b.account, amount: BigInt(b.amount), remark: b.comment }, (s) => {
if (s === 'signing') step = { kind: 'signing' };
else if (!sent) {
sent = true;
onsent();
wait(b);
}
});
if (!sent) {
sent = true;
onsent();
wait(b);
}
} catch (e) {
const f: Failure = failure(e);
// Once on its way, the chain may still take it: keep waiting.
if (sent && f === 'node') return;
if (nothingSent(f)) {
if (f === 'account') remember(null);
await api.del(`/ballots/${b.id}`).catch(() => undefined);
}
stop();
step = { kind: 'failed', text: t(`sign.error.${f}`) };
}
}
async function gecko() {
step = { kind: 'opening' };
const b = await seal(null);
if (!b) return;
ballot = b;
const link = geckoLink(b.account, b.amount, b.comment);
step = { kind: 'gecko', link, qr: await qrPath(link) };
wait(b);
}
function wait(b: Ballot) {
if (step.kind !== 'gecko') step = { kind: 'waiting' };
// Under the article, the vote now shows as waiting for the chain.
void doc.reload();
poll(b);
}
function poll(b: Ballot) {
stop();
timer = setInterval(async () => {
let now: Ballot;
try {
now = (await api.get<{ ballot: Ballot }>(`/ballots/${b.id}`)).ballot;
} catch {
return;
}
if (now.status === 'pending' || closed) return;
stop();
ballot = now;
await doc.reload();
if (now.status === 'confirmed') {
onsent();
step = { kind: 'confirmed', voter: now.voter };
announce(doc, now.entered);
} else if (now.status === 'refused') {
step = { kind: 'refused', reason: now.reason };
} else {
step = { kind: 'expired' };
}
}, 3000);
}
</script>
<dialog bind:this={dialog} class="sign card" aria-labelledby="sign-title" onclose={() => !closed && close()}>
<h2 id="sign-title">{t('sign.title')}</h2>
<p class="summary">{i18n.plural('sign.summary', lines.length, { amount })}</p>
<div class="step" aria-live="polite">
{#if step.kind === 'start' || step.kind === 'opening'}
<p class="muted">{t('sign.opening')}</p>
{:else if step.kind === 'none'}
<p>{t('sign.none')}</p>
{:else if step.kind === 'wallets'}
<p class="label">{t('sign.wallet')}</p>
<ul class="choices">
{#each step.list as w (w.id)}
<li><button class="btn" type="button" onclick={() => choose(w)}>{w.id}</button></li>
{/each}
</ul>
{:else if step.kind === 'accounts'}
{@const wallet = step.wallet}
<p class="label">{t('sign.account')}</p>
<ul class="choices">
{#each step.list as a (a.address)}
<li>
<button class="btn account" type="button" onclick={() => go(wallet, a)}>
{#if a.name}<span>{a.name}</span>{/if}<code>{short(a.address)}</code>
</button>
</li>
{/each}
</ul>
{:else if step.kind === 'signing'}
<p class="now"><span class="dot" aria-hidden="true"></span>{t('sign.signing')}</p>
{:else if step.kind === 'waiting'}
<p class="now"><span class="dot" aria-hidden="true"></span>{t('sign.waiting')}</p>
<p class="faint small">{t('sign.later')}</p>
{:else if step.kind === 'gecko' && ballot}
<p>{t('sign.gecko.scan')}</p>
<div class="gecko">
<svg class="qr" viewBox="0 0 {step.qr.size} {step.qr.size}" role="img" aria-label={t('sign.gecko.qr')} shape-rendering="crispEdges">
<path d={step.qr.d} fill="currentColor" />
</svg>
<dl>
<dt>{t('sign.gecko.amount')}</dt>
<dd>{amount} Ğ1</dd>
<dt>{t('sign.gecko.comment')}</dt>
<dd><code class="comment">{ballot.comment}</code></dd>
</dl>
</div>
<a class="btn primary" href={step.link}>{t('sign.gecko.open')}</a>
<p class="now"><span class="dot" aria-hidden="true"></span>{t('sign.waiting')}</p>
<p class="faint small">{t('sign.later')}</p>
{:else if step.kind === 'confirmed'}
<p class="ok">{t('sign.confirmed')}</p>
{#if step.voter}<p class="muted">{t('sign.confirmed.as', { name: step.voter })}</p>{/if}
{:else if step.kind === 'refused'}
<p class="warn" role="alert">{t('sign.refused', { reason: t(`ballot.reason.${step.reason ?? 'transfer'}`) })}</p>
{:else if step.kind === 'expired'}
<p class="warn" role="alert">{t('sign.expired')}</p>
{:else if step.kind === 'failed'}
<p class="warn" role="alert">{step.text}</p>
{/if}
</div>
<div class="actions">
{#if geckoOffered}
<button class="btn quiet" type="button" onclick={gecko}>{t('sign.gecko')}</button>
{/if}
{#if step.kind === 'failed' || step.kind === 'expired' || step.kind === 'refused'}
<button class="btn" type="button" onclick={start}>{t('sign.retry')}</button>
{/if}
<button class="btn {step.kind === 'confirmed' ? 'primary' : 'quiet'}" type="button" onclick={close}>{t('sign.close')}</button>
</div>
</dialog>
<style>
.sign {
border: 0;
padding: 1.5rem;
width: min(94vw, 30rem);
max-height: 92dvh;
overflow-y: auto;
color: var(--ink);
display: grid;
gap: 1rem;
}
.sign:not([open]) {
display: none;
}
.sign::backdrop {
background: var(--veil);
}
h2 {
font-family: var(--font-text);
font-weight: 500;
font-size: 1.35rem;
}
.summary {
color: var(--ink-soft);
}
.step {
display: grid;
gap: 0.7rem;
}
.label {
font-weight: 800;
font-size: 0.9rem;
}
.choices {
list-style: none;
margin: 0;
padding: 0;
display: grid;
gap: 0.4rem;
}
.choices .btn {
width: 100%;
justify-content: flex-start;
}
.account {
gap: 0.7rem;
}
code {
font-size: 0.82rem;
}
.now {
display: flex;
align-items: center;
gap: 0.55rem;
font-weight: 750;
color: var(--water);
}
.dot {
flex: none;
width: 0.6rem;
height: 0.6rem;
border-radius: 50%;
background: currentColor;
animation: breathe 1.6s ease-in-out infinite;
}
.gecko {
display: grid;
grid-template-columns: minmax(9rem, 12rem) 1fr;
gap: 1rem;
align-items: center;
}
/* A code to scan reads dark on light, whatever the mood. */
.qr {
width: 100%;
height: auto;
padding: 0.4rem;
border-radius: var(--r-input);
background: #fff;
color: #111;
}
dl {
margin: 0;
display: grid;
gap: 0.15rem;
}
dt {
font-size: 0.78rem;
font-weight: 800;
color: var(--ink-soft);
}
dd {
margin: 0 0 0.5rem;
font-weight: 750;
}
.comment {
word-break: break-all;
font-weight: 600;
}
.ok {
font-weight: 800;
color: var(--moss);
}
.warn {
color: var(--ochre);
font-weight: 700;
}
.small {
font-size: 0.84rem;
}
.actions {
display: flex;
flex-wrap: wrap;
justify-content: flex-end;
gap: 0.5rem;
}
@keyframes breathe {
50% {
opacity: 0.35;
}
}
@media (max-width: 480px) {
.gecko {
grid-template-columns: 1fr;
justify-items: center;
}
.qr {
max-width: 13rem;
}
}
@media (prefers-reduced-motion: reduce) {
.dot {
animation: none;
}
}
</style>
@@ -0,0 +1,73 @@
<script lang="ts">
// One choice of the basket: for, against, or withdraw one's vote (when there is
// one). Pressing the chosen one again takes the version out of the basket.
import { t } from '$lib/i18n/index.svelte';
import type { Choice } from '$lib/types';
let {
value,
voted,
label,
disabled = false,
onpick,
}: { value: Choice | undefined; voted: boolean; label: string; disabled?: boolean; onpick: (c: Choice) => void } = $props();
</script>
<span class="picker" role="group" aria-label={label}>
<button type="button" class="for" aria-pressed={value === 'for'} {disabled} onclick={() => onpick('for')}>{t('vote.for')}</button>
<button type="button" class="against" aria-pressed={value === 'against'} {disabled} onclick={() => onpick('against')}>{t('vote.against')}</button>
{#if voted || value === 'none'}
<button type="button" class="none" aria-pressed={value === 'none'} {disabled} onclick={() => onpick('none')}>{t('basket.pick.retract')}</button>
{/if}
</span>
<style>
.picker {
display: inline-flex;
flex-wrap: wrap;
gap: 0.25rem;
background: var(--surface-2);
padding: 3px;
border-radius: var(--r-pill);
}
button {
border: 0;
background: transparent;
border-radius: var(--r-pill);
padding: 0.3rem 0.75rem;
min-height: 2.25rem;
min-width: 2.75rem;
font-weight: 750;
font-size: 0.84rem;
color: var(--ink-soft);
transition: background-color 0.15s var(--ease), color 0.15s var(--ease);
}
button:hover:not(:disabled) {
color: var(--ink);
background: var(--surface);
}
.for[aria-pressed='true'],
.for[aria-pressed='true']:hover {
background: var(--moss);
color: var(--on-water);
}
.against[aria-pressed='true'],
.against[aria-pressed='true']:hover {
background: var(--ochre);
color: var(--on-water);
}
.none[aria-pressed='true'],
.none[aria-pressed='true']:hover {
background: var(--ink-soft);
color: var(--surface);
}
button:disabled {
opacity: 0.5;
cursor: not-allowed;
}
@media (prefers-reduced-motion: reduce) {
button {
transition: none;
}
}
</style>
+52 -12
View File
@@ -1,8 +1,9 @@
<script lang="ts">
// The open proposals, one line per article (Yvv, 2026-10-30). An article with a
// single version opens its vote; one with several shows the best of them and
// unfolds the others, each with its label, its water level and its marks — and
// each opens its own vote.
// single version unfolds its vote below it; one with several shows the best of
// them and unfolds the others, each with its label, its water level and its
// marks — and each unfolds its own vote.
import type { Snippet } from 'svelte';
import { plain } from '$lib/markdown';
import { inertiaBadge, relationOf, titleOf } from '$lib/labels';
import { i18n, t, type Key } from '$lib/i18n/index.svelte';
@@ -13,16 +14,26 @@
let {
articles,
selected,
onopen,
}: { articles: Article[]; selected: string | null; onopen: (unit: Unit, p: Proposal) => void } = $props();
vote,
ontoggle,
fold,
onsection,
}: {
articles: Article[];
/** The version whose vote is unfolded. */
vote: string | null;
ontoggle: (unit: Unit, p: Proposal) => void;
fold: Snippet<[Unit, Proposal]>;
/** Opens the basket on a section; absent when this session does not vote here. */
onsection?: (section: string) => void;
} = $props();
const SECTIONS = ['introduction', 'fondamental', 'technique', 'conclusion', 'annexe', 'formule', 'inertie', 'ordonnancement'];
const sectionLabel = (s: string | null) => (s && SECTIONS.includes(s) ? t(`section.${s}` as Key) : null);
// Unfolded by hand, or because the version open in the panel is inside.
// Unfolded by hand, or because the version whose vote is unfolded is inside.
let unfolded = $state<Record<string, boolean>>({});
const isOpen = (a: Article) => unfolded[a.unit.id] ?? a.versions.some((p) => p.id === selected);
const isOpen = (a: Article) => unfolded[a.unit.id] ?? a.versions.some((p) => p.id === vote);
const toggle = (a: Article) => (unfolded[a.unit.id] = !isOpen(a));
/** The title of the article: its best text version, or what the best proposal is. */
@@ -61,11 +72,17 @@
{@const unit = a.unit}
{@const best = a.versions[0]}
{#if sectionLabel(unit.section) && unit.section !== articles[i - 1]?.unit.section}
<li class="section eyebrow">{sectionLabel(unit.section)}</li>
<li class="section">
<span class="eyebrow">{sectionLabel(unit.section)}</span>
{#if onsection && unit.section}
{@const key = unit.section}
<button type="button" class="vote-section" onclick={() => onsection(key)}>{t('basket.section')}</button>
{/if}
</li>
{/if}
{#if best && a.versions.length === 1}
<li class="article">
<button type="button" class="row" class:current={selected === best.id} onclick={() => onopen(unit, best)}>
<li class="article" class:open={vote === best.id}>
<button type="button" class="row" class:current={vote === best.id} aria-expanded={vote === best.id} aria-controls="vote-{best.id}" onclick={() => ontoggle(unit, best)}>
<WaterLevel tally={best.tally} evaluation={best.evaluation} blocked={best.blocked} />
<span class="main">
<span class="title">{titleOf(best, unit)}</span>
@@ -78,6 +95,7 @@
</span>
</span>
</button>
{#if vote === best.id}<div class="fold">{@render fold(unit, best)}</div>{/if}
</li>
{:else if best}
{@const open = isOpen(a)}
@@ -105,7 +123,7 @@
<ul class="versions" id="versions-{unit.id}" hidden={!open}>
{#each a.versions as p (p.id)}
<li>
<button type="button" class="version" class:current={selected === p.id} onclick={() => onopen(unit, p)}>
<button type="button" class="version" class:current={vote === p.id} aria-expanded={vote === p.id} aria-controls="vote-{p.id}" onclick={() => ontoggle(unit, p)}>
<WaterLevel tally={p.tally} evaluation={p.evaluation} blocked={p.blocked} />
<span class="main">
<span class="label">{labelOf(p, a)}{#if p.kind === 'text' && p.title !== title}<span class="other"> · {p.title}</span>{/if}</span>
@@ -116,6 +134,7 @@
</span>
</span>
</button>
{#if vote === p.id}{@render fold(unit, p)}{/if}
</li>
{/each}
</ul>
@@ -234,9 +253,30 @@
color: var(--ink-soft);
}
.section {
display: flex;
flex-wrap: wrap;
align-items: center;
justify-content: space-between;
gap: 0.3rem 1rem;
margin: 1.6rem 0 0.3rem;
color: var(--ink-soft);
}
.vote-section {
border: 0;
background: transparent;
color: var(--water);
font-weight: 750;
font-size: 0.84rem;
padding: 0.3rem 0.7rem;
min-height: 2.25rem;
border-radius: var(--r-pill);
}
.vote-section:hover {
background: var(--water-wash);
}
.fold {
padding: 0 1rem 1rem;
}
.section:first-child {
margin-top: 0.4rem;
}
+39 -11
View File
@@ -1,21 +1,43 @@
<script lang="ts">
// The side panel: the basket of the vote, the versions of an article, a new
// proposal, the details of a version, the settings of the formula. The vote of
// one version unfolds under its article, not here.
import type { DocState, PanelState } from '$lib/doc.svelte';
import { t } from '$lib/i18n/index.svelte';
import { relationOf, titleOf } from '$lib/labels';
import type { BasketState } from '$lib/picks.svelte';
import type { Lang } from '$lib/types';
import type { Line } from '$lib/vote';
import BallotPanel from './BallotPanel.svelte';
import FormulaSettings from './FormulaSettings.svelte';
import Icon from './Icon.svelte';
import ProposeView from './ProposeView.svelte';
import VoteView from './VoteView.svelte';
import VersionDetails from './VersionDetails.svelte';
import WaterLevel from './WaterLevel.svelte';
let {
doc,
panel,
lang,
basket,
vote,
onchange,
onclose,
}: { doc: DocState; panel: PanelState; lang: Lang; onchange: (p: PanelState) => void; onclose: () => void } = $props();
onvote,
onsign,
}: {
doc: DocState;
panel: PanelState;
lang: Lang;
basket: BasketState;
/** The version whose vote is unfolded under its article. */
vote: string | null;
onchange: (p: PanelState) => void;
onclose: () => void;
/** Unfolds the vote of a version under its article; `close` closes the panel. */
onvote: (proposalId: string, close: boolean) => void;
onsign: (lines: Line[]) => void;
} = $props();
const unit = $derived(doc.unit(panel.unitId));
const versions = $derived(
@@ -25,9 +47,11 @@
const tabs = $derived(
(
[
['vote', t('panel.vote'), !!voteId],
['basket', basket.size ? `${t('panel.basket')} · ${basket.size}` : t('panel.basket'), !!doc.view?.vote.open || panel.tab === 'basket'],
['proposals', `${t('panel.proposals')}${versions.length > 1 ? ` · ${versions.length}` : ''}`, !!unit],
['propose', t('panel.propose'), true],
// A version: reached from its vote, or from the list of versions.
['details', t('panel.details'), !!panel.proposalId],
// Reached from the head of the document or from a vote; shown while it is open.
['settings', t('panel.settings'), panel.tab === 'settings'],
] as const
@@ -49,7 +73,7 @@
});
</script>
<aside class="panel card" aria-label={t('panel.vote')}>
<aside class="panel card" aria-label={tabs.find(([key]) => key === panel.tab)?.[1] ?? t('panel.proposals')}>
<div class="bar">
<div class="tabs" role="tablist" bind:this={tablist}>
{#each tabs as [key, label] (key)}
@@ -60,25 +84,26 @@
</div>
<div class="body" bind:this={body}>
{#if panel.tab === 'vote' && voteId}
<VoteView
{#if panel.tab === 'basket'}
<BallotPanel {doc} {basket} section={panel.section ?? null} {onsign} onread={(id) => onvote(id, false)} />
{:else if panel.tab === 'details' && voteId}
<VersionDetails
{doc}
proposalId={voteId}
{lang}
onopen={(id) => onchange({ ...panel, proposalId: id, tab: 'vote' })}
onvote={(id) => onvote(id, true)}
onpropose={(prefill) => onchange({ ...panel, tab: 'propose', prefill, draftId: null })}
onsettings={() => onchange({ ...panel, proposalId: voteId, tab: 'settings' })}
/>
{:else if panel.tab === 'proposals' && unit}
<ul class="versions">
{#each versions as v (v.id)}
<li>
<button type="button" class="version" class:current={v.id === voteId} onclick={() => onchange({ ...panel, proposalId: v.id, tab: 'vote' })}>
<button type="button" class="version" class:current={v.id === vote} onclick={() => onvote(v.id, true)}>
<WaterLevel tally={v.tally} evaluation={v.evaluation} blocked={v.blocked} />
<span class="text">
<span class="eyebrow">{v.status === 'in_force' ? t('panel.inforce') : t('panel.proposal')}</span>
<span class="title">{titleOf(v, unit)}</span>
<span class="faint small">{v.status === 'in_force' ? (v.seedMinimal ? t('doc.seed') : '') : relationOf(v, unit)} · {t('tally.short', { for: v.tally.for, against: v.tally.against })}</span>
<span class="faint small">{v.status === 'in_force' ? (v.seedMinimal ? t('doc.seed') : '') : relationOf(v, unit)} · {t('tally.short', { for: v.tally.for, against: v.tally.against })}{#if v.myVote && !v.myVoteStale} · {t('basket.mine', { choice: t(`vote.choice.${v.myVote}`) })}{/if}</span>
</span>
</button>
</li>
@@ -91,7 +116,10 @@
unitId={panel.unitId}
draftId={panel.draftId ?? null}
prefill={panel.prefill ?? null}
ondone={(r) => onchange(r.proposalId ? { unitId: doc.proposal(r.proposalId)?.unitId ?? panel.unitId, proposalId: r.proposalId, tab: 'vote' } : { ...panel, tab: 'proposals' })}
ondone={(r) =>
r.proposalId && r.submitted
? onvote(r.proposalId, true)
: onchange(r.proposalId ? { unitId: doc.proposal(r.proposalId)?.unitId ?? panel.unitId, proposalId: r.proposalId, tab: 'details' } : { ...panel, tab: 'proposals' })}
/>
{:else if panel.tab === 'settings'}
<FormulaSettings {doc} proposalId={voteId} />
+33 -5
View File
@@ -31,6 +31,17 @@
/** A tool is highlighted only when one is in it. */
const inTool = (tool: Tool) => here !== null && (path === `/g/${here}/${tool}` || path.startsWith(`/g/${here}/${tool}/`));
// A radio group: one stop for Tab, the arrows move to the next mood and choose it.
function onMoodKey(e: KeyboardEvent) {
const step = ({ ArrowRight: 1, ArrowDown: 1, ArrowLeft: -1, ArrowUp: -1 } as Record<string, number>)[e.key];
if (!step) return;
e.preventDefault();
const next = MOODS[(MOODS.indexOf(mood.current) + step + MOODS.length) % MOODS.length];
if (!next) return;
mood.set(next);
(e.currentTarget as HTMLElement).querySelector<HTMLElement>(`[data-mood="${next}"]`)?.focus();
}
async function signOut() {
await session.logout();
groups.reset();
@@ -85,7 +96,7 @@
<button type="button" class:on={i18n.lang === l} aria-pressed={i18n.lang === l} onclick={() => i18n.set(l)}>{l.toUpperCase()}</button>
{/each}
</div>
<div class="moods" role="radiogroup" aria-label={t('nav.mood')}>
<div class="moods" role="radiogroup" aria-label={t('nav.mood')} tabindex="-1" onkeydown={onMoodKey}>
{#each MOODS as m (m)}
<!-- Each swatch carries its own mood: its colours are that mood's tokens. -->
<button
@@ -96,6 +107,7 @@
aria-checked={mood.current === m}
aria-label={t(`mood.${m}`)}
title={t(`mood.${m}`)}
tabindex={mood.current === m ? 0 : -1}
onclick={() => mood.set(m)}
></button>
{/each}
@@ -242,7 +254,15 @@
.moods {
display: inline-flex;
align-items: center;
gap: 0.3rem;
gap: 0.6rem;
padding-inline: 0.2rem;
/* Taken here, in the mood of the page, before each swatch carries its own:
the rings read against the page whatever the colour of the swatch. */
--page-ink: var(--ink);
--page-ring: var(--water);
}
.moods:focus {
outline: none;
}
.swatch {
position: relative;
@@ -262,10 +282,18 @@
border-radius: 50%;
background: var(--cle);
}
/* Chosen: a ring of the page's ink, close to the swatch. */
.swatch[aria-checked='true'] {
/* currentColor is the ink of the page, inherited. */
outline: 2px solid currentColor;
outline-offset: 2px;
box-shadow:
inset 0 0 0 1px color-mix(in srgb, var(--ink) 32%, transparent),
0 0 0 2px var(--page-ink);
}
/* Focused: further out, in the page's accent (3:1 or more on the page in every
mood), and dashed: never mistaken for the chosen one. */
.swatch:focus-visible {
outline: 2px dashed var(--page-ring);
outline-offset: 4px;
border-radius: 50%;
}
.who {
display: flex;
+19 -6
View File
@@ -1,4 +1,5 @@
<script lang="ts">
import type { Snippet } from 'svelte';
import type { Shown } from '$lib/doc.svelte';
import { i18n, t } from '$lib/i18n/index.svelte';
import Prose from './Prose.svelte';
@@ -11,6 +12,9 @@
rivals = 0,
selected = false,
actions = true,
voteFor = null,
voteOpen = false,
fold,
onaction,
}: {
text: Shown;
@@ -20,11 +24,16 @@
rivals?: number;
selected?: boolean;
actions?: boolean;
onaction: (tab: 'vote' | 'proposals' | 'propose') => void;
/** The version « Le vote » unfolds, and whether it is unfolded. */
voteFor?: string | null;
voteOpen?: boolean;
/** The vote, unfolded under the text. */
fold?: Snippet;
onaction: (action: 'vote' | 'proposals' | 'propose') => void;
} = $props();
</script>
<article class="unit {mark}" class:selected>
<article class="unit {mark}" class:selected class:unfolded={voteOpen}>
<header>
<h2 class="eyebrow">{text.title}</h2>
{#if mark !== 'unchanged'}<span class="tag {mark === 'removed' ? 'ochre' : 'moss'}">{t(`doc.mark.${mark}`)}</span>{/if}
@@ -38,12 +47,13 @@
{:else if enteredAt}{t('doc.adopted', { date: i18n.date(enteredAt) })}{/if}
</span>
<span class="links">
<button type="button" onclick={() => onaction('vote')}>{t('doc.action.vote')}</button>
<button type="button" class:on={voteOpen} aria-expanded={voteOpen} aria-controls={voteFor ? `vote-${voteFor}` : undefined} onclick={() => onaction('vote')}>{t('doc.action.vote')}</button>
<button type="button" onclick={() => onaction('proposals')}>{t('doc.action.proposals')}{#if rivals > 0} · {rivals}{/if}</button>
<button type="button" onclick={() => onaction('propose')}>{t('doc.action.propose')}</button>
</span>
</footer>
{/if}
{#if voteOpen && fold}{@render fold()}{/if}
</article>
<style>
@@ -59,7 +69,8 @@
}
.unit:hover,
.unit:focus-within,
.unit.selected {
.unit.selected,
.unit.unfolded {
background: var(--surface);
box-shadow: var(--shadow);
}
@@ -98,7 +109,8 @@
}
.unit:hover .links,
.unit:focus-within .links,
.unit.selected .links {
.unit.selected .links,
.unit.unfolded .links {
opacity: 1;
}
.links button {
@@ -110,7 +122,8 @@
min-height: 2.25rem;
border-radius: var(--r-pill);
}
.links button:hover {
.links button:hover,
.links button.on {
background: var(--water-wash);
}
@media (hover: none) {
@@ -1,30 +1,30 @@
<script lang="ts">
// A version in the panel, tab « Détails »: its text and what changed, then
// what is not the vote — typos, translations, flags of contradiction, history.
// The vote itself unfolds under the article (VoteFold).
import { api, ApiError } from '$lib/api';
import { shown, type DocState } from '$lib/doc.svelte';
import { i18n, t, type Key } from '$lib/i18n/index.svelte';
import { inertiaBadge, titleOf } from '$lib/labels';
import { titleOf } from '$lib/labels';
import { toasts } from '$lib/toast.svelte';
import type { DiffPart, Entered, Inertia, Lang, LevelInfo, Proposal } from '$lib/types';
import { announce } from '$lib/vote';
import type { DiffPart, Entered, Inertia, Lang, Proposal } from '$lib/types';
import DiffText from './DiffText.svelte';
import Formula from './Formula.svelte';
import InertiaPicker from './InertiaPicker.svelte';
import Prose from './Prose.svelte';
import WaterLevel from './WaterLevel.svelte';
let {
doc,
proposalId,
lang,
onopen,
onvote,
onpropose,
onsettings,
}: {
doc: DocState;
proposalId: string;
lang: Lang;
onopen: (proposalId: string) => void;
/** Unfolds the vote of this version under its article. */
onvote: (proposalId: string) => void;
onpropose: (prefill: { title: string; body: string }) => void;
onsettings?: () => void;
} = $props();
interface Detail {
@@ -37,9 +37,6 @@
id: string; reason: string; author: string; authorInvited: boolean; mine: boolean; at: string; liftedAt: string | null; liftedBy: string | null;
other: { id: string; title: string; status: string; code: string | null } | null;
}[];
electorate: number;
voteFloor: number;
levels: LevelInfo[];
}
let detail = $state<Detail | null>(null);
@@ -101,29 +98,6 @@
await load();
}
function announce(entered: Entered[]) {
for (const e of entered) {
const u = doc.unit(e.unitId);
const title = u?.inForce?.title ?? u?.code ?? '';
const key = e.kind === 'remove' ? 'vote.entered.remove' : e.kind === 'inertia' ? 'vote.entered.inertia' : 'vote.entered';
toasts.show(t(key, { title }), 'rise', 6000);
}
}
async function vote(choice: 'for' | 'against' | null) {
busy = true;
try {
const r = await api.post<{ entered: Entered[] }>(`/proposals/${proposalId}/vote`, { choice });
toasts.show(t(choice ? 'vote.saved' : 'vote.retracted'));
await refresh();
announce(r.entered);
} catch (e) {
error = message(e);
} finally {
busy = false;
}
}
async function saveTypo(e: SubmitEvent) {
e.preventDefault();
busy = true;
@@ -179,7 +153,7 @@
try {
const r = await api.post<{ entered: Entered[] }>(`/signals/${id}/lift`);
await refresh();
announce(r.entered);
announce(doc, r.entered);
} catch (err) {
error = message(err);
} finally {
@@ -202,7 +176,7 @@
</script>
{#if detail && p && text && view}
<article class="vote">
<article class="details">
<header>
<span class="eyebrow">{p.status === 'in_force' ? t('panel.inforce') : t('panel.proposal')}</span>
<h3>{p.kind === 'text' ? text.title : titleOf(p, unit)}</h3>
@@ -228,39 +202,12 @@
<del class="removed"><Prose text={detail.inForce.body} /></del>
{/if}
<section class="box">
<div class="level">
<WaterLevel tally={p.tally} evaluation={p.evaluation} blocked={p.blocked} size="large" />
<div class="numbers">
<p class="tally">{t('tally.short', { for: p.tally.for, against: p.tally.against })}</p>
{#if p.evaluation}
<p class="muted">{t('tally.required', { n: p.evaluation.required })}</p>
<p class="state">{p.evaluation.reached ? t('tally.reached') : i18n.plural('tally.missing', p.evaluation.missing)}</p>
{/if}
<p class="faint small">{inertiaBadge(p.votedAtInertia)}</p>
</div>
</div>
{#if p.blocked}<p class="warn">{t('vote.blocked')}</p>{/if}
{#if votable && !view?.canVote}
<p class="muted">{t('vote.wotOnly')}</p>
{:else if votable}
{#if p.myVoteStale}<p class="warn">{t('vote.stale')}</p>{/if}
<div class="choice" role="group" aria-label={t('vote.yours')}>
<span class="label">{t('vote.yours')}</span>
<button class="btn" class:on={p.myVote === 'for' && !p.myVoteStale} aria-pressed={p.myVote === 'for' && !p.myVoteStale} disabled={busy} onclick={() => vote('for')}>{t('vote.for')}</button>
<button class="btn" class:on-against={p.myVote === 'against' && !p.myVoteStale} aria-pressed={p.myVote === 'against' && !p.myVoteStale} disabled={busy} onclick={() => vote('against')}>{t('vote.against')}</button>
{#if p.myVote}<button class="btn quiet small" disabled={busy} onclick={() => vote(null)}>{t('vote.retract')}</button>{/if}
</div>
<p class="faint small">{t('vote.private')}</p>
{:else}
<p class="muted">{t('vote.closed')}</p>
{/if}
</section>
{#if p.kind !== 'inertia' && unit}<InertiaPicker {doc} {unit} {onopen} />{/if}
<Formula electorate={detail.electorate} source={view.doc.electorateSource} tally={p.tally} evaluation={p.evaluation} inertia={p.votedAtInertia} levels={detail.levels} floor={detail.voteFloor} base={view.doc.formula.base} {onsettings} />
{#if votable}
<p class="tally faint">
{t('tally.short', { for: p.tally.for, against: p.tally.against })}
· <button class="linkish" type="button" onclick={() => onvote(p.id)}>{t('doc.action.vote')}</button>
</p>
{/if}
{#if p.kind === 'text' && (p.status === 'in_force' || p.origin === 'seed' || p.mine) && votable}
<section class="sub">
@@ -351,7 +298,7 @@
{/if}
<style>
.vote {
.details {
display: grid;
gap: 1.1rem;
}
@@ -382,48 +329,19 @@
.removed {
opacity: 0.6;
}
.box {
display: grid;
gap: 0.8rem;
padding: 1rem;
border-radius: var(--r-card);
background: var(--water-wash);
}
.level {
display: flex;
gap: 1rem;
align-items: center;
}
.numbers {
display: grid;
gap: 0.1rem;
}
.tally {
font-size: 1.15rem;
font-weight: 800;
font-size: 0.88rem;
font-weight: 700;
font-variant-numeric: tabular-nums;
}
.state {
font-weight: 700;
}
.choice {
display: flex;
flex-wrap: wrap;
align-items: center;
gap: 0.45rem;
}
.label {
font-weight: 800;
font-size: 0.88rem;
margin-right: 0.3rem;
}
.btn.on {
background: var(--moss);
color: var(--on-water);
}
.btn.on-against {
background: var(--ochre);
color: var(--on-water);
.linkish {
border: 0;
background: none;
padding: 0;
color: var(--water);
font-weight: 750;
text-decoration: underline;
text-underline-offset: 3px;
}
.warn {
color: var(--ochre);
+284
View File
@@ -0,0 +1,284 @@
<script lang="ts">
// The vote, unfolded under the article (Yvv, 2026-10-30: « il faut voir son
// vote et les autres. À combien il se rapproche du passage en vigueur. Façon
// toggle, pas une fenêtre qui s'ouvre »): the level of the water, the totals —
// never who voted what —, how far the threshold is, your vote and where it
// stands, then the inertia and the formula. The rest of a version (typos,
// translations, flags, history) lives in the panel, tab « Détails ».
import { onMount } from 'svelte';
import { api } from '$lib/api';
import type { DocState } from '$lib/doc.svelte';
import { i18n, t } from '$lib/i18n/index.svelte';
import { inertiaBadge, titleOf } from '$lib/labels';
import { toasts } from '$lib/toast.svelte';
import type { LevelInfo, Proposal, Unit } from '$lib/types';
import { errorText, g1Amount, voteDirect, type Line } from '$lib/vote';
import Formula from './Formula.svelte';
import InertiaPicker from './InertiaPicker.svelte';
import WaterLevel from './WaterLevel.svelte';
let {
doc,
proposal: p,
unit,
onopen,
onsettings,
ondetails,
onsign,
}: {
doc: DocState;
proposal: Proposal;
unit: Unit;
/** Unfolds the vote of another version (a change of inertia). */
onopen: (proposalId: string) => void;
onsettings: (proposalId: string) => void;
ondetails: (proposalId: string) => void;
/** On the Ğ1 chain: the page has the lines signed by the person's wallet. */
onsign: (lines: Line[]) => void;
} = $props();
interface Detail {
electorate: number;
voteFloor: number;
levels: LevelInfo[];
}
let detail = $state<Detail | null>(null);
let busy = $state(false);
let error = $state('');
let root: HTMLElement | undefined = $state();
const view = $derived(doc.view);
const access = $derived(view?.vote);
const chain = $derived(access?.way === 'chain');
const votable = $derived(p.status === 'open' || p.status === 'in_force');
const title = $derived(p.kind === 'text' ? p.title : titleOf(p, unit));
const voted = $derived(p.myVote !== null && !p.myVoteStale);
const ballot = $derived(p.myBallot);
$effect(() => {
const id = p.id;
void p.tally.for;
void p.tally.against;
api
.get<Detail>(`/proposals/${id}`)
.then((d) => {
if (p.id === id) detail = d;
})
.catch(() => (detail = null));
});
onMount(() => {
// Opened by a link: brought into sight, without a jump if it already is.
const reduce = matchMedia('(prefers-reduced-motion: reduce)').matches;
root?.scrollIntoView({ block: 'nearest', behavior: reduce ? 'auto' : 'smooth' });
});
async function cast(choice: 'for' | 'against' | null) {
error = '';
const lines: Line[] = [{ proposalId: p.id, choice }];
if (chain) {
onsign(lines);
return;
}
busy = true;
try {
await voteDirect(doc, lines);
toasts.show(t(choice ? 'vote.saved' : 'vote.retracted'));
} catch (e) {
error = errorText(e);
} finally {
busy = false;
}
}
</script>
<section class="fold" id="vote-{p.id}" aria-label={t('vote.fold', { title })} bind:this={root}>
<div class="level">
<WaterLevel tally={p.tally} evaluation={p.evaluation} blocked={p.blocked} size="large" />
<div class="numbers">
<p class="tally">{t('tally.short', { for: p.tally.for, against: p.tally.against })}</p>
{#if p.evaluation}
<p class="muted">{t('tally.required', { n: p.evaluation.required })}</p>
<p class="state">{p.evaluation.reached ? t('tally.reached') : i18n.plural('tally.missing', p.evaluation.missing)}</p>
{/if}
<p class="faint small">{inertiaBadge(p.votedAtInertia)}</p>
</div>
</div>
{#if p.blocked}<p class="warn">{t('vote.blocked')}</p>{/if}
<div class="mine" aria-live="polite">
{#if ballot?.status === 'pending'}
<p class="pending"><span class="dot" aria-hidden="true"></span>{t('vote.mine.pending', { choice: t(`vote.choice.${ballot.choice}`) })}</p>
{/if}
{#if p.myVoteStale}
<p class="warn">{t('vote.stale')}</p>
{:else if p.myVote}
<p class="voted {p.myVote}">{t(p.myVote === 'for' ? 'vote.mine.for' : 'vote.mine.against')} · <span class="how">{t(p.myVoteChain ? 'vote.mine.chain' : 'vote.mine.saved')}</span></p>
{:else if ballot?.status !== 'pending'}
<p class="muted">{t('vote.mine.none')}</p>
{/if}
{#if ballot?.status === 'refused'}
<p class="warn">{t('vote.mine.refused', { reason: t(`ballot.reason.${ballot.reason ?? 'transfer'}`) })}</p>
{:else if ballot?.status === 'expired'}
<p class="warn">{t('vote.mine.expired')}</p>
{/if}
</div>
{#if !votable}
<p class="muted">{t('vote.closed')}</p>
{:else if access && !access.open}
<p class="closed">{t(access.reason === 'chain_off' ? 'vote.chain.off' : 'vote.members')}</p>
{:else if access}
<div class="choice" role="group" aria-label={t('vote.yours')}>
<button class="btn for" class:on={p.myVote === 'for' && voted} aria-pressed={p.myVote === 'for' && voted} disabled={busy} onclick={() => cast('for')}>{t('vote.for')}</button>
<button class="btn against" class:on={p.myVote === 'against' && voted} aria-pressed={p.myVote === 'against' && voted} disabled={busy} onclick={() => cast('against')}>{t('vote.against')}</button>
{#if p.myVote}<button class="btn quiet small" disabled={busy} onclick={() => cast(null)}>{t('vote.retract')}</button>{/if}
</div>
{#if chain && access.chain}
<p class="faint small">{t('vote.chain.note', { amount: g1Amount(access.chain.amount) })}</p>
<p class="faint small">{t('vote.chain.counts')}</p>
{/if}
{/if}
<p class="faint small">{t('vote.private')}</p>
{#if error}<p class="warn" role="alert">{error}</p>{/if}
<div class="more">
{#if p.kind !== 'inertia'}<InertiaPicker {doc} {unit} {onopen} />{/if}
{#if detail && view}
<Formula
electorate={detail.electorate}
source={view.doc.electorateSource}
tally={p.tally}
evaluation={p.evaluation}
inertia={p.votedAtInertia}
levels={detail.levels}
floor={detail.voteFloor}
base={view.doc.formula.base}
onsettings={() => onsettings(p.id)}
/>
{/if}
<button class="linkish" type="button" onclick={() => ondetails(p.id)}>{t('vote.details')}</button>
</div>
</section>
<style>
.fold {
display: grid;
gap: 0.8rem;
margin-top: 0.8rem;
padding: 1.1rem 1.1rem 1.2rem;
border-radius: var(--r-card);
background: var(--water-wash);
animation: unfold 0.22s var(--ease);
scroll-margin: 1.5rem;
}
.level {
display: flex;
gap: 1rem;
align-items: center;
}
.numbers {
display: grid;
gap: 0.1rem;
}
.tally {
font-size: 1.15rem;
font-weight: 800;
font-variant-numeric: tabular-nums;
}
.state {
font-weight: 700;
}
.small {
font-size: 0.84rem;
}
.mine {
display: grid;
gap: 0.3rem;
}
.voted {
font-weight: 800;
}
.voted.for {
color: var(--moss);
}
.voted.against {
color: var(--ochre);
}
.how {
font-weight: 650;
color: var(--ink-soft);
}
.pending {
display: flex;
align-items: center;
gap: 0.5rem;
font-weight: 750;
color: var(--water);
}
.dot {
width: 0.55rem;
height: 0.55rem;
border-radius: 50%;
background: currentColor;
animation: breathe 1.6s ease-in-out infinite;
}
.choice {
display: flex;
flex-wrap: wrap;
align-items: center;
gap: 0.45rem;
}
.btn.for.on {
background: var(--moss);
color: var(--on-water);
}
.btn.against.on {
background: var(--ochre);
color: var(--on-water);
}
.closed {
font-weight: 700;
color: var(--ink-soft);
}
.warn {
color: var(--ochre);
font-weight: 700;
font-size: 0.9rem;
}
.more {
display: grid;
gap: 0.9rem;
padding-top: 0.4rem;
}
.linkish {
justify-self: start;
border: 0;
background: none;
padding: 0.2rem 0;
min-height: 2.25rem;
color: var(--water);
font-weight: 750;
text-align: left;
text-decoration: underline;
text-underline-offset: 3px;
}
@keyframes unfold {
from {
opacity: 0;
transform: translateY(-4px);
}
}
@keyframes breathe {
50% {
opacity: 0.35;
}
}
@media (prefers-reduced-motion: reduce) {
.fold,
.dot {
animation: none;
}
}
</style>
+6 -2
View File
@@ -1,19 +1,23 @@
// State of the reader: the document, the view (in force / previews), the
// language the text is read in, and the panel (which unit, which version,
// which tab) — the panel lives in the URL so a link reopens it.
// which tab) — the panel lives in the URL so a link reopens it, as does the
// vote unfolded under an article (`?vote=`).
import { api, ApiError } from './api';
import type { GroupView } from './group';
import type { DocView, Lang, PreviewUnit, Proposal, Translation, Unit } from './types';
export type View = 'inforce' | 'wellrated' | 'all';
export type Tab = 'vote' | 'proposals' | 'propose' | 'settings';
/** The panel: the basket of the vote, the versions of an article, a new proposal, the details of a version, the formula. */
export type Tab = 'basket' | 'proposals' | 'propose' | 'details' | 'settings';
export interface PanelState {
unitId: string | null;
proposalId: string | null;
tab: Tab;
draftId?: string | null;
/** The basket, opened on one section. */
section?: string | null;
prefill?: { title: string; body: string } | null;
}
+83 -2
View File
@@ -122,6 +122,8 @@ export const en: Messages = {
'doc.action.vote': 'Voting',
'doc.action.proposals': 'Proposals',
'doc.action.propose': 'Propose a change',
'doc.basket': 'Vote in bulk',
'doc.basket.count': 'Vote in bulk · {n}',
'doc.empty': 'No article is in force. The proposals below move up as soon as they reach their threshold.',
'doc.published': 'Published to the text repository',
'doc.publish.pending': 'Publication pending',
@@ -176,6 +178,8 @@ export const en: Messages = {
'panel.proposals': 'Proposals',
'panel.propose': 'Propose',
'panel.settings': 'Settings',
'panel.basket': 'Basket',
'panel.details': 'Details',
'panel.close': 'Close panel',
'panel.inforce': 'In force',
'panel.proposal': 'Proposal',
@@ -199,7 +203,76 @@ export const en: Messages = {
'vote.entered.remove': '“{title}” has been removed from the text in force.',
'vote.entered.inertia': 'The inertia of “{title}” has changed.',
'vote.closed': 'This version is no longer open for voting.',
'vote.wotOnly': 'Here, only members of the Ğ1 web of trust vote: sign in with your Ğ1 key to vote. You can read, propose and flag a contradiction.',
'vote.fold': 'The vote on “{title}”',
'vote.mine.none': 'You have not voted on this version.',
'vote.mine.for': 'Your vote: for',
'vote.mine.against': 'Your vote: against',
'vote.mine.chain': 'recorded on the Ğ1 chain',
'vote.mine.saved': 'saved',
'vote.mine.pending': 'Waiting for the Ğ1 chain: {choice}.',
'vote.mine.refused': 'Your last vote was not counted: {reason}',
'vote.mine.expired': 'Your last vote was not seen on the Ğ1 chain in time: it does not count.',
'vote.choice.for': 'for',
'vote.choice.against': 'against',
'vote.choice.none': 'withdrawing your vote',
'vote.chain.note': 'Your vote leaves from your Ğ1 wallet: {amount} Ğ1, in a single gesture. The chain shows that you voted, never what you voted.',
'vote.chain.counts': 'Your voice counts for the Ğ1 identity of the wallet that signs, if it is a member of the web of trust.',
'vote.chain.off': 'Voting through the Ğ1 chain is not open yet: the account that receives the votes is still to be created. You can read, propose and flag a contradiction.',
'vote.members': 'Only members of this group vote here.',
'vote.details': 'Typos, translations, flags, history',
'ballot.reason.not_member': 'the wallet that signed is not that of a member of the Ğ1 web of trust.',
'ballot.reason.no_identity': 'the wallet that signed is linked to no Ğ1 identity.',
'ballot.reason.transfer': 'the transfer to the vote account is missing, or its amount is too low.',
'basket.title': 'Voting basket',
'basket.intro': 'Choose article by article, a whole section or the whole document: it all goes in a single gesture.',
'basket.doc': 'The whole document',
'basket.for': 'All for',
'basket.against': 'All against',
'basket.clear': 'Empty',
'basket.pick': 'Your choice for “{title}”',
'basket.pick.retract': 'Withdraw',
'basket.versions': 'Several versions: choose yours.',
'basket.waiting.one': '{n} article has several versions: a grouped gesture does not fill it, choose your version.',
'basket.waiting.other': '{n} articles have several versions: a grouped gesture does not fill them, choose your version.',
'basket.inforce': 'in force',
'basket.count.one': '{n} choice',
'basket.count.other': '{n} choices',
'basket.send.server': 'Vote ({n})',
'basket.send.chain': 'Sign and send ({n})',
'basket.note.server': 'Your votes count as soon as they are sent, all at once.',
'basket.note.chain': 'A single transaction of {amount} Ğ1, signed by your wallet, for all your choices.',
'basket.empty': 'Nothing in the basket yet.',
'basket.sent.one': '{n} vote saved.',
'basket.sent.other': '{n} votes saved.',
'basket.section': 'Vote on this section',
'basket.mine': 'your vote: {choice}',
'sign.title': 'Vote with your Ğ1 wallet',
'sign.summary.one': '{n} vote, in one transaction: {amount} Ğ1 to the vote account, with your sealed ballot.',
'sign.summary.other': '{n} votes, in one transaction: {amount} Ğ1 to the vote account, with your sealed ballot.',
'sign.wallet': 'With which wallet?',
'sign.account': 'With which account?',
'sign.opening': 'Your wallet is opening…',
'sign.signing': 'Confirm in your wallet.',
'sign.waiting': 'The Ğ1 chain is confirming your vote, usually in less than a minute.',
'sign.confirmed': 'Your vote counts: it is recorded on the Ğ1 chain.',
'sign.confirmed.as': 'Counted for the Ğ1 identity “{name}”.',
'sign.refused': 'Your vote was not counted: {reason}',
'sign.expired': 'Your vote was not seen on the Ğ1 chain in time: it does not count. Start again whenever you like.',
'sign.error.cancelled': 'You declined to sign: nothing was sent.',
'sign.error.funds': 'Your wallet does not have enough Ğ1: at least 1 Ğ1 must remain after the vote.',
'sign.error.node': 'The Ğ1 chain is not answering. Try again in a moment.',
'sign.error.account': 'This account is no longer in your wallet.',
'sign.error.wallet': 'Your wallet could not sign. Try again, or use Ğecko.',
'sign.none': 'No wallet is connected to this browser (duniter-connect, for example). On your phone, Ğecko does the job.',
'sign.gecko': 'Use Ğecko',
'sign.gecko.scan': 'Scan this code with Ğecko, or tap the link on your phone. Check the amount and the comment, then confirm.',
'sign.gecko.open': 'Open in Ğecko',
'sign.gecko.amount': 'Amount',
'sign.gecko.comment': 'Comment',
'sign.gecko.qr': 'Code to scan with Ğecko',
'sign.close': 'Close',
'sign.later': 'You can close: the confirmation will arrive on its own, and your vote will appear under the article.',
'sign.retry': 'Try again',
'inertia.title': 'Inertia',
'inertia.explain': 'The higher the inertia, the more votes it takes to change this text when few people vote.',
@@ -395,13 +468,21 @@ export const en: Messages = {
'error.identity_kind': 'A new link can only be for an invited person, never a Ğ1 identity.',
'error.hidden_characters': 'The text contains invisible characters (writing-direction override, zero-width space, soft hyphen, emoji colour selector…): remove them.',
'error.changed': 'The text has changed in the meantime: reload it, then try again.',
'error.wot_members_only': 'Here, only members of the Ğ1 web of trust vote.',
'error.label': 'The name is empty or too long (60 characters at most).',
'error.name': 'Your name has 2 to 60 characters, on one line: letters, figures, spaces, hyphen, apostrophe or dot.',
'error.name_taken': 'This name is already taken. Choose another one, adding an initial for instance.',
'error.team_limits': 'A team link lasts 1 to 90 days, for 1 to 500 people.',
'error.groups': 'Choose at least one group.',
'error.publication_off': 'Publication is not configured on this server.',
'error.chain_vote_off': 'Voting through the Ğ1 chain is not open yet.',
'error.chain_only': 'Here, the vote goes through the Ğ1 chain, with your wallet.',
'error.server_vote': 'Here, voting is direct, without a wallet.',
'error.too_many_ballots': 'You already have many votes waiting for the Ğ1 chain: wait for them to be confirmed.',
'error.lines': 'This vote no longer holds: reload the page, then start again.',
'error.choice': 'This choice does not exist.',
'error.author': 'This account is not a Ğ1 address.',
'error.not_votable': 'This version is no longer open for voting.',
'error.members_only': 'Only members of this group vote here.',
'error.retry': 'Try again',
'common.cancel': 'Cancel',
+83 -2
View File
@@ -122,6 +122,8 @@ export const es: Messages = {
'doc.action.vote': 'Votación',
'doc.action.proposals': 'Propuestas',
'doc.action.propose': 'Proponer una modificación',
'doc.basket': 'Votar en bloque',
'doc.basket.count': 'Votar en bloque · {n}',
'doc.empty': 'Ningún artículo está en vigor. Las propuestas de abajo suben en cuanto alcanzan su umbral.',
'doc.published': 'Publicado en el repositorio de textos',
'doc.publish.pending': 'Publicación pendiente',
@@ -176,6 +178,8 @@ export const es: Messages = {
'panel.proposals': 'Propuestas',
'panel.propose': 'Proponer',
'panel.settings': 'Ajustes',
'panel.basket': 'Cesta',
'panel.details': 'Detalles',
'panel.close': 'Cerrar el panel',
'panel.inforce': 'En vigor',
'panel.proposal': 'Propuesta',
@@ -199,7 +203,76 @@ export const es: Messages = {
'vote.entered.remove': '«{title}» se ha retirado del texto en vigor.',
'vote.entered.inertia': 'La inercia de «{title}» ha cambiado.',
'vote.closed': 'Esta versión ya no está en votación.',
'vote.wotOnly': 'Aquí solo votan los miembros de la red de confianza Ğ1: conéctate con tu clave Ğ1 para votar. Puedes leer, proponer y señalar una contradicción.',
'vote.fold': 'La votación de «{title}»',
'vote.mine.none': 'No has votado esta versión.',
'vote.mine.for': 'Tu voto: a favor',
'vote.mine.against': 'Tu voto: en contra',
'vote.mine.chain': 'inscrito en la cadena Ğ1',
'vote.mine.saved': 'registrado',
'vote.mine.pending': 'Esperando a la cadena Ğ1: {choice}.',
'vote.mine.refused': 'Tu último voto no se contó: {reason}',
'vote.mine.expired': 'Tu último voto no se vio a tiempo en la cadena Ğ1: no cuenta.',
'vote.choice.for': 'a favor',
'vote.choice.against': 'en contra',
'vote.choice.none': 'retirar tu voto',
'vote.chain.note': 'Tu voto sale de tu monedero Ğ1: {amount} Ğ1, en un solo gesto. La cadena muestra que has votado, nunca lo que has votado.',
'vote.chain.counts': 'Tu voz cuenta para la identidad Ğ1 del monedero que firma, si es miembro de la red de confianza.',
'vote.chain.off': 'La votación por la cadena Ğ1 aún no está abierta: falta crear la cuenta que recibe los votos. Puedes leer, proponer y señalar una contradicción.',
'vote.members': 'Aquí solo votan los miembros de este grupo.',
'vote.details': 'Erratas, traducciones, señalamientos, historial',
'ballot.reason.not_member': 'el monedero que firmó no es el de un miembro de la red de confianza Ğ1.',
'ballot.reason.no_identity': 'el monedero que firmó no está vinculado a ninguna identidad Ğ1.',
'ballot.reason.transfer': 'falta la transferencia a la cuenta de los votos, o su importe es demasiado bajo.',
'basket.title': 'Cesta de votación',
'basket.intro': 'Elige artículo por artículo, una sección entera o todo el documento: todo sale en un solo gesto.',
'basket.doc': 'Todo el documento',
'basket.for': 'Todo a favor',
'basket.against': 'Todo en contra',
'basket.clear': 'Vaciar',
'basket.pick': 'Tu elección para «{title}»',
'basket.pick.retract': 'Retirar',
'basket.versions': 'Varias versiones: elige la tuya.',
'basket.waiting.one': '{n} artículo tiene varias versiones: un gesto agrupado no lo rellena, elige tu versión.',
'basket.waiting.other': '{n} artículos tienen varias versiones: un gesto agrupado no los rellena, elige tu versión.',
'basket.inforce': 'en vigor',
'basket.count.one': '{n} elección',
'basket.count.other': '{n} elecciones',
'basket.send.server': 'Votar ({n})',
'basket.send.chain': 'Firmar y enviar ({n})',
'basket.note.server': 'Tus votos cuentan en cuanto se envían, de una sola vez.',
'basket.note.chain': 'Una sola transacción de {amount} Ğ1, firmada por tu monedero, para todas tus elecciones.',
'basket.empty': 'Nada en la cesta por ahora.',
'basket.sent.one': '{n} voto registrado.',
'basket.sent.other': '{n} votos registrados.',
'basket.section': 'Votar esta sección',
'basket.mine': 'tu voto: {choice}',
'sign.title': 'Votar con tu monedero Ğ1',
'sign.summary.one': '{n} voto, en una transacción: {amount} Ğ1 a la cuenta de los votos, con tu papeleta sellada.',
'sign.summary.other': '{n} votos, en una transacción: {amount} Ğ1 a la cuenta de los votos, con tu papeleta sellada.',
'sign.wallet': '¿Con qué monedero?',
'sign.account': '¿Con qué cuenta?',
'sign.opening': 'Tu monedero se abre…',
'sign.signing': 'Confirma en tu monedero.',
'sign.waiting': 'La cadena Ğ1 confirma tu voto, en general en menos de un minuto.',
'sign.confirmed': 'Tu voto cuenta: está inscrito en la cadena Ğ1.',
'sign.confirmed.as': 'Contado para la identidad Ğ1 «{name}».',
'sign.refused': 'Tu voto no se contó: {reason}',
'sign.expired': 'Tu voto no se vio a tiempo en la cadena Ğ1: no cuenta. Vuelve a empezar cuando quieras.',
'sign.error.cancelled': 'Rechazaste la firma: no se envió nada.',
'sign.error.funds': 'Tu monedero no tiene suficientes Ğ1: debe quedarle al menos 1 Ğ1 después del voto.',
'sign.error.node': 'La cadena Ğ1 no responde. Vuelve a intentarlo en un momento.',
'sign.error.account': 'Esta cuenta ya no está en tu monedero.',
'sign.error.wallet': 'Tu monedero no pudo firmar. Vuelve a intentarlo, o usa Ğecko.',
'sign.none': 'Ningún monedero está conectado a este navegador (duniter-connect, por ejemplo). En tu teléfono, Ğecko sirve.',
'sign.gecko': 'Usar Ğecko',
'sign.gecko.scan': 'Escanea este código con Ğecko, o toca el enlace en tu teléfono. Comprueba el importe y el comentario, y luego valida.',
'sign.gecko.open': 'Abrir en Ğecko',
'sign.gecko.amount': 'Importe',
'sign.gecko.comment': 'Comentario',
'sign.gecko.qr': 'Código para escanear con Ğecko',
'sign.close': 'Cerrar',
'sign.later': 'Puedes cerrar: la confirmación llegará sola, y tu voto aparecerá bajo el artículo.',
'sign.retry': 'Reintentar',
'inertia.title': 'Inercia',
'inertia.explain': 'Cuanto más alta es la inercia, más votos hacen falta para cambiar este texto cuando vota poca gente.',
@@ -395,13 +468,21 @@ export const es: Messages = {
'error.identity_kind': 'Un nuevo enlace solo puede ser para una persona invitada, nunca para una identidad Ğ1.',
'error.hidden_characters': 'El texto contiene caracteres invisibles (inversión del sentido de escritura, espacio de ancho cero, guion condicional, selector de color de un emoji…): quítalos.',
'error.changed': 'El texto ha cambiado mientras tanto: vuelve a cargarlo y empieza de nuevo.',
'error.wot_members_only': 'Aquí solo votan los miembros de la red de confianza Ğ1.',
'error.label': 'El nombre está vacío o es demasiado largo (60 caracteres como máximo).',
'error.name': 'Tu nombre tiene de 2 a 60 caracteres, en una línea: letras, cifras, espacios, guion, apóstrofo o punto.',
'error.name_taken': 'Este nombre ya está ocupado. Elige otro, añadiendo una inicial por ejemplo.',
'error.team_limits': 'Un enlace de equipo vale de 1 a 90 días, para 1 a 500 personas.',
'error.groups': 'Elige al menos un grupo.',
'error.publication_off': 'La publicación no está configurada en este servidor.',
'error.chain_vote_off': 'La votación por la cadena Ğ1 aún no está abierta.',
'error.chain_only': 'Aquí, el voto pasa por la cadena Ğ1, con tu monedero.',
'error.server_vote': 'Aquí se vota directamente, sin monedero.',
'error.too_many_ballots': 'Ya tienes muchos votos esperando a la cadena Ğ1: espera a que se confirmen.',
'error.lines': 'Este voto ya no vale: recarga la página y vuelve a empezar.',
'error.choice': 'Esta elección no existe.',
'error.author': 'Esta cuenta no es una dirección Ğ1.',
'error.not_votable': 'Esta versión ya no está en votación.',
'error.members_only': 'Aquí solo votan los miembros de este grupo.',
'error.retry': 'Reintentar',
'common.cancel': 'Cancelar',
+83 -2
View File
@@ -125,6 +125,8 @@ export const fr = {
'doc.action.vote': 'Le vote',
'doc.action.proposals': 'Propositions',
'doc.action.propose': 'Proposer une modification',
'doc.basket': 'Voter en lot',
'doc.basket.count': 'Voter en lot · {n}',
'doc.empty': "Aucun article n'est en vigueur. Les propositions ci-dessous remontent dès qu'elles atteignent leur seuil.",
'doc.published': 'Publié sur le dépôt des textes',
'doc.publish.pending': 'Publication en attente',
@@ -179,6 +181,8 @@ export const fr = {
'panel.proposals': 'Propositions',
'panel.propose': 'Proposer',
'panel.settings': 'Réglages',
'panel.basket': 'Panier',
'panel.details': 'Détails',
'panel.close': 'Fermer le panneau',
'panel.inforce': 'En vigueur',
'panel.proposal': 'Proposition',
@@ -202,7 +206,76 @@ export const fr = {
'vote.entered.remove': '« {title} » est retiré du texte en vigueur.',
'vote.entered.inertia': "L'inertie de « {title} » a changé.",
'vote.closed': 'Cette version ne se vote plus.',
'vote.wotOnly': 'Ici, seuls les membres de la toile de confiance Ğ1 votent : connecte-toi avec ta clé Ğ1 pour voter. Tu peux lire, proposer et signaler une contradiction.',
'vote.fold': 'Le vote de « {title} »',
'vote.mine.none': "Tu n'as pas voté sur cette version.",
'vote.mine.for': 'Ton vote : pour',
'vote.mine.against': 'Ton vote : contre',
'vote.mine.chain': 'inscrit sur la chaîne Ğ1',
'vote.mine.saved': 'enregistré',
'vote.mine.pending': 'En attente de la chaîne Ğ1 : {choice}.',
'vote.mine.refused': "Ton dernier vote n'a pas été compté : {reason}",
'vote.mine.expired': "Ton dernier vote n'a pas été vu sur la chaîne Ğ1 à temps : il ne compte pas.",
'vote.choice.for': 'pour',
'vote.choice.against': 'contre',
'vote.choice.none': 'retrait de ton vote',
'vote.chain.note': 'Ton vote part de ton portefeuille Ğ1 : {amount} Ğ1, en un seul geste. La chaîne montre que tu as voté, jamais ce que tu as voté.',
'vote.chain.counts': "Ta voix compte pour l'identité Ğ1 du portefeuille qui signe, si elle est membre de la toile de confiance.",
'vote.chain.off': "Le vote par la chaîne Ğ1 n'est pas encore ouvert : le compte qui reçoit les votes reste à créer. Tu peux lire, proposer et signaler une contradiction.",
'vote.members': 'Seuls les membres de ce groupe votent ici.',
'vote.details': 'Coquilles, traductions, signalements, historique',
'ballot.reason.not_member': "le portefeuille qui a signé n'est pas celui d'un membre de la toile de confiance Ğ1.",
'ballot.reason.no_identity': "le portefeuille qui a signé n'est rattaché à aucune identité Ğ1.",
'ballot.reason.transfer': 'le virement vers le compte des votes manque, ou son montant est trop faible.',
'basket.title': 'Panier de vote',
'basket.intro': 'Choisis article par article, une section entière ou tout le document : tout part en un seul geste.',
'basket.doc': 'Tout le document',
'basket.for': 'Tout pour',
'basket.against': 'Tout contre',
'basket.clear': 'Vider',
'basket.pick': 'Ton choix pour « {title} »',
'basket.pick.retract': 'Retirer',
'basket.versions': 'Plusieurs versions : choisis la tienne.',
'basket.waiting.one': '{n} article a plusieurs versions : un geste groupé ne le remplit pas, choisis ta version.',
'basket.waiting.other': '{n} articles ont plusieurs versions : un geste groupé ne les remplit pas, choisis ta version.',
'basket.inforce': 'en vigueur',
'basket.count.one': '{n} choix',
'basket.count.other': '{n} choix',
'basket.send.server': 'Voter ({n})',
'basket.send.chain': 'Signer et envoyer ({n})',
'basket.note.server': "Tes votes comptent dès l'envoi, en une seule fois.",
'basket.note.chain': 'Une seule transaction de {amount} Ğ1, signée par ton portefeuille, pour tous tes choix.',
'basket.empty': "Rien dans le panier pour l'instant.",
'basket.sent.one': '{n} vote enregistré.',
'basket.sent.other': '{n} votes enregistrés.',
'basket.section': 'Voter cette section',
'basket.mine': 'ton vote : {choice}',
'sign.title': 'Voter avec ton portefeuille Ğ1',
'sign.summary.one': '{n} vote, en une transaction : {amount} Ğ1 vers le compte des votes, avec ton bulletin scellé.',
'sign.summary.other': '{n} votes, en une transaction : {amount} Ğ1 vers le compte des votes, avec ton bulletin scellé.',
'sign.wallet': 'Avec quel portefeuille ?',
'sign.account': 'Avec quel compte ?',
'sign.opening': "Ton portefeuille s'ouvre…",
'sign.signing': 'Confirme dans ton portefeuille.',
'sign.waiting': "La chaîne Ğ1 confirme ton vote, en général en moins d'une minute.",
'sign.confirmed': 'Ton vote compte : il est inscrit sur la chaîne Ğ1.',
'sign.confirmed.as': "Compté pour l'identité Ğ1 « {name} ».",
'sign.refused': "Ton vote n'a pas été compté : {reason}",
'sign.expired': "Ton vote n'a pas été vu sur la chaîne Ğ1 à temps : il ne compte pas. Recommence quand tu veux.",
'sign.error.cancelled': "Tu as refusé la signature : rien n'est parti.",
'sign.error.funds': "Ton portefeuille n'a pas assez de Ğ1 : il doit lui rester au moins 1 Ğ1 après le vote.",
'sign.error.node': 'La chaîne Ğ1 ne répond pas. Réessaie dans un moment.',
'sign.error.account': "Ce compte n'est plus dans ton portefeuille.",
'sign.error.wallet': "Ton portefeuille n'a pas pu signer. Réessaie, ou passe par Ğecko.",
'sign.none': "Aucun portefeuille n'est branché à ce navigateur (duniter-connect, par exemple). Sur ton téléphone, Ğecko fait l'affaire.",
'sign.gecko': 'Passer par Ğecko',
'sign.gecko.scan': 'Scanne ce code avec Ğecko, ou touche le lien sur ton téléphone. Vérifie le montant et le commentaire, puis valide.',
'sign.gecko.open': 'Ouvrir dans Ğecko',
'sign.gecko.amount': 'Montant',
'sign.gecko.comment': 'Commentaire',
'sign.gecko.qr': 'Code à scanner avec Ğecko',
'sign.close': 'Fermer',
'sign.later': "Tu peux fermer : la confirmation arrivera seule, et ton vote apparaîtra sous l'article.",
'sign.retry': 'Réessayer',
'inertia.title': 'Inertie',
'inertia.explain': "Plus l'inertie est haute, plus il faut de voix pour changer ce texte quand peu de gens votent.",
@@ -398,13 +471,21 @@ export const fr = {
'error.identity_kind': "Un nouveau lien ne peut viser qu'une personne invitée, jamais une identité Ğ1.",
'error.hidden_characters': "Le texte contient des caractères invisibles (inversion du sens d'écriture, espace de largeur nulle, trait d'union conditionnel, sélecteur de couleur d'un emoji…) : retire-les.",
'error.changed': 'Le texte a changé entre-temps : recharge-le, puis recommence.',
'error.wot_members_only': 'Ici, seuls les membres de la toile de confiance Ğ1 votent.',
'error.label': 'Le nom est vide ou trop long (60 signes au plus).',
'error.name': "Ton nom fait de 2 à 60 signes, sur une ligne : lettres, chiffres, espaces, trait d'union, apostrophe ou point.",
'error.name_taken': 'Ce nom est déjà pris. Choisis-en un autre, en ajoutant une initiale par exemple.',
'error.team_limits': "Un lien d'équipe vaut de 1 à 90 jours, pour 1 à 500 personnes.",
'error.groups': 'Choisis au moins un groupe.',
'error.publication_off': "La publication n'est pas configurée sur ce serveur.",
'error.chain_vote_off': "Le vote par la chaîne Ğ1 n'est pas encore ouvert.",
'error.chain_only': 'Ici, le vote passe par la chaîne Ğ1, avec ton portefeuille.',
'error.server_vote': 'Ici, le vote se fait directement, sans portefeuille.',
'error.too_many_ballots': 'Tu as déjà beaucoup de votes en attente de la chaîne Ğ1 : attends leur confirmation.',
'error.lines': 'Ce vote ne tient plus : recharge la page, puis recommence.',
'error.choice': "Ce choix n'existe pas.",
'error.author': "Ce compte n'est pas une adresse Ğ1.",
'error.not_votable': 'Cette version ne se vote plus.',
'error.members_only': 'Seuls les membres de ce groupe votent ici.',
'error.retry': 'Réessayer',
'common.cancel': 'Annuler',
+61
View File
@@ -0,0 +1,61 @@
// The basket of the reader: the choices not sent yet, kept for the tab (a reload
// or a look at another article does not lose them), one basket per document.
import type { Picks } from './basket';
import type { Choice } from './types';
export class BasketState {
picks = $state<Picks>({});
#key = '';
/** Takes back the basket of this document, keeping only the versions still open to vote. */
load(docId: string, known: Set<string>) {
const key = `ld:basket:${docId}`;
if (key === this.#key) {
this.#keep(known);
return;
}
this.#key = key;
let saved: Picks = {};
try {
saved = JSON.parse(sessionStorage.getItem(key) ?? '{}') as Picks;
} catch {
saved = {};
}
this.picks = {};
this.set(saved);
this.#keep(known);
}
#keep(known: Set<string>) {
const kept = Object.fromEntries(Object.entries(this.picks).filter(([id]) => known.has(id)));
if (Object.keys(kept).length !== Object.keys(this.picks).length) this.set(kept);
}
set(picks: Picks) {
const clean: Picks = {};
for (const [id, c] of Object.entries(picks)) if (c === 'for' || c === 'against' || c === 'none') clean[id] = c;
this.picks = clean;
try {
sessionStorage.setItem(this.#key, JSON.stringify(clean));
} catch {
/* private mode: the basket lives as long as the page */
}
}
/** Picks a choice; the same choice again takes it out of the basket. */
pick(id: string, choice: Choice) {
const next = { ...this.picks };
if (next[id] === choice) delete next[id];
else next[id] = choice;
this.set(next);
}
clear() {
this.set({});
}
get size(): number {
return Object.keys(this.picks).length;
}
}
+52 -1
View File
@@ -100,9 +100,59 @@ export interface Proposal {
signals: number;
myVote: 'for' | 'against' | null;
myVoteStale: boolean;
/** « Ton vote » was cast on the Ğ1 chain (confirmed). */
myVoteChain: boolean;
/** A ballot of this session not counted yet: pending on the chain, or refused, or let go. */
myBallot: MyBallot | null;
translations: Partial<Record<Lang, Translation>>;
}
export type Choice = 'for' | 'against' | 'none';
export interface MyBallot {
id: string;
status: 'pending' | 'refused' | 'expired';
reason: 'not_member' | 'no_identity' | 'transfer' | null;
choice: Choice;
}
/** The vote on the Ğ1 chain, as the server sets it (ADR 0008). */
export interface ChainSettings {
/** The vote account, as a Ğ1 address. */
account: string;
/** Cents of Ğ1 per vote. */
amount: number;
/** WebSocket endpoints of Ğ1 nodes. */
nodes: string[];
/** Tests only: the page sends to a fake node, without building a real transaction. */
test: boolean;
testNode: string | null;
expiresHours: number;
}
/** How this session votes in the group of the document, and whether it may now. */
export interface VoteAccess {
way: 'server' | 'chain';
open: boolean;
reason: 'members_only' | 'chain_off' | null;
chain: ChainSettings | null;
}
/** A ballot of the chain, as `GET /api/ballots/{id}` tells it to the session that asked. */
export interface Ballot {
id: string;
status: 'pending' | 'confirmed' | 'refused' | 'expired';
reason: MyBallot['reason'];
comment: string;
account: string;
amount: number;
author: string | null;
block: number | null;
voter: string | null;
entered: Entered[];
expiresAt: string;
}
export interface Unit {
id: string;
code: string;
@@ -124,8 +174,9 @@ export interface Publication {
}
export interface DocView {
/** trustinWallet: where the electorate is the web of trust, only its members vote. */
/** This session may vote now (`vote.open`). */
canVote: boolean;
vote: VoteAccess;
doc: {
id: string;
slug: string;
+43
View File
@@ -0,0 +1,43 @@
// What the vote under an article and the basket share: one way to send votes
// to the server (managed groups), and the news of what entered into force.
import { api, ApiError } from './api';
import type { DocState } from './doc.svelte';
import { i18n, t, type Key } from './i18n/index.svelte';
import { toasts } from './toast.svelte';
import type { Entered } from './types';
/** A line of a vote: one version, for or against, or null to withdraw one's vote. */
export interface Line {
proposalId: string;
choice: 'for' | 'against' | null;
}
/** Cents of Ğ1, as the person reads them: « 0,1 » in French. */
export function g1Amount(cents: number): string {
return i18n.number(cents / 100);
}
export function errorText(e: unknown): string {
return t(`error.${e instanceof ApiError ? e.code : 'internal'}` as Key);
}
/** Tells, one by one, the texts that just entered into force. */
export function announce(doc: DocState, entered: Entered[]) {
for (const e of entered) {
const u = doc.unit(e.unitId);
const title = u?.inForce?.title ?? u?.code ?? '';
const key = e.kind === 'remove' ? 'vote.entered.remove' : e.kind === 'inertia' ? 'vote.entered.inertia' : 'vote.entered';
toasts.show(t(key, { title }), 'rise', 6000);
}
}
/** In a managed group: every line in one write on the server. Returns how many were written. */
export async function voteDirect(doc: DocState, lines: Line[]): Promise<number> {
const id = doc.view?.doc.id;
if (!id) return 0;
const r = await api.post<{ count: number; entered: Entered[] }>(`/docs/${id}/votes`, { lines });
await doc.reload();
announce(doc, r.entered);
return r.count;
}
+32
View File
@@ -0,0 +1,32 @@
// Ğecko, on a phone (or without a wallet in the browser): a link and a QR code
// that open the transfer with its amount and its comment already written. Ğecko
// does not hand back to the page: the page waits for the indexer to see it.
/** Cents of Ğ1 as Ğ1, with a dot: 10 → « 0.1 ». */
export function amountText(cents: number): string {
const whole = Math.trunc(cents / 100);
const rest = cents % 100;
if (!rest) return String(whole);
return `${whole}.${String(rest).padStart(2, '0').replace(/0$/, '')}`;
}
/**
* `g1://<account>?amount=0.1&comment=ld1:<hex>` — the format Ğecko and Ğ1nkgo read.
* The comment is plain ASCII (`ld1:` and hex digits): nothing to escape.
*/
export function geckoLink(account: string, cents: number, comment: string): string {
return `g1://${account}?amount=${amountText(cents)}&comment=${comment}`;
}
/** The dark modules of a QR code as one SVG path, `size` modules wide (border included). */
export async function qrPath(text: string): Promise<{ size: number; d: string }> {
const { encode } = await import('uqr');
const qr = encode(text, { ecc: 'M', border: 2 });
let d = '';
qr.data.forEach((row, y) => {
row.forEach((dark, x) => {
if (dark) d += `M${x} ${y}h1v1h-1z`;
});
});
return { size: qr.data.length, d };
}
+206
View File
@@ -0,0 +1,206 @@
// The wallets of the page: every browser extension that offers the standard
// interface of Substrate wallets (`window.injectedWeb3`, `signPayload`) —
// duniter-connect today; Ğ1Companion and trustinWallet the day they offer it.
// One adapter, one interface: the vote never knows which wallet signs.
//
// The transaction is one gesture (Yvv, 2026-10-30): `utility.batchAll` of the
// transfer to the vote account and the remark that seals the ballot, built by
// polkadot-api — loaded only now, at the moment of signing — signed by the
// extension (its own confirmation window), sent to a Ğ1 node. The server then
// finds it on the indexer.
import type { ChainSettings } from '../types';
export interface WalletAccount {
address: string;
name: string | null;
}
export interface VoteTransfer {
/** The vote account. */
dest: string;
/** Cents of Ğ1. */
amount: bigint;
/** `ld1:` + the seal of the ballot. */
remark: string;
}
/** signing: the wallet asks the person; sent: on its way to the chain; included: in a block. */
export type SendStatus = 'signing' | 'sent' | 'included';
export type Failure = 'cancelled' | 'funds' | 'node' | 'account' | 'wallet';
export class WalletError extends Error {
constructor(public code: Failure) {
super(code);
}
}
export interface Wallet {
/** The name the extension gives itself in `window.injectedWeb3`. */
id: string;
accounts(): Promise<WalletAccount[]>;
send(account: WalletAccount, tx: VoteTransfer, on: (s: SendStatus) => void): Promise<void>;
}
interface Enabled {
accounts: { get: () => Promise<{ address: string; name?: string }[]> };
signer: { signPayload?: (payload: Record<string, unknown>) => Promise<{ signature: string }> };
}
interface Injected {
enable?: (dapp: string) => Promise<Enabled>;
}
type Page = { injectedWeb3?: Record<string, unknown> };
const DAPP = 'libreDecision';
/** Wallets of the Ğ1 first, then the others, in the order they were found. */
const PREFERRED = ['duniter-connect', 'g1companion', 'trustinwallet'];
const rank = (name: string) => {
const i = PREFERRED.indexOf(name.toLowerCase());
return i < 0 ? PREFERRED.length : i;
};
export function injectedNames(page: Page): string[] {
return Object.entries(page.injectedWeb3 ?? {})
.filter(([, v]) => typeof (v as Injected | null)?.enable === 'function')
.map(([name]) => name)
.sort((a, b) => rank(a) - rank(b));
}
/** What went wrong, in one of a few words the interface translates. */
export function failure(e: unknown): Failure {
if (e instanceof WalletError) return e.code;
let text: string;
if (e instanceof Error) text = e.message;
else {
try {
text = JSON.stringify(e) ?? String(e);
} catch {
text = String(e);
}
}
text = text.toLowerCase();
if (/cancel|reject|denied|declined/.test(text)) return 'cancelled';
if (/payment|funds|balance|existential|pay some fees/.test(text)) return 'funds';
return 'wallet';
}
/** The wallet stopped before anything reached the chain, or the chain undid it all: no vote will come. */
export function nothingSent(f: Failure): boolean {
return f === 'cancelled' || f === 'funds' || f === 'account';
}
const hex = (bytes: Uint8Array) => Array.from(bytes, (b) => b.toString(16).padStart(2, '0')).join('');
/** Tests only: what the fake node receives, readable JSON as hex. Never a real transaction. */
export function testExtrinsic(fields: Record<string, string>): string {
return `0x${hex(new TextEncoder().encode(JSON.stringify(fields)))}`;
}
async function enable(name: string): Promise<Enabled> {
const injected = (window as Page).injectedWeb3?.[name] as Injected | undefined;
if (!injected?.enable) throw new WalletError('wallet');
try {
return await injected.enable(DAPP);
} catch (e) {
throw new WalletError(failure(e));
}
}
/** Tests: the fake extension signs, the fake node records; no polkadot-api, no chain. */
async function sendTest(name: string, node: string, account: WalletAccount, tx: VoteTransfer, on: (s: SendStatus) => void) {
const ext = await enable(name);
if (!ext.signer.signPayload) throw new WalletError('wallet');
const fields = { address: account.address, dest: tx.dest, amount: String(tx.amount), remark: tx.remark };
on('signing');
let signature: string;
try {
({ signature } = await ext.signer.signPayload({ address: account.address, method: testExtrinsic(fields), version: 4 }));
} catch (e) {
throw new WalletError(failure(e));
}
const body = { jsonrpc: '2.0', id: 1, method: 'author_submitExtrinsic', params: [testExtrinsic({ ...fields, signature })] };
const res = await fetch(node, { method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify(body) }).catch(
() => null,
);
if (!res?.ok) throw new WalletError('node');
on('sent');
}
/** The real thing: polkadot-api builds the transaction from the chain's own description. */
async function sendChain(name: string, nodes: string[], account: WalletAccount, tx: VoteTransfer, on: (s: SendStatus) => void) {
const [{ createClient, Binary }, { getWsProvider }, { connectInjectedExtension }] = await Promise.all([
import('polkadot-api'),
import('polkadot-api/ws'),
import('polkadot-api/pjs-signer'),
]);
const ext = await connectInjectedExtension(name, DAPP).catch((e: unknown) => {
throw new WalletError(failure(e));
});
const client = createClient(getWsProvider(nodes));
try {
const signer = ext.getAccounts().find((a) => a.address === account.address);
if (!signer) throw new WalletError('account');
// The calls are read from the chain's own metadata: pallets Utility, Balances and System of Duniter v2.
const { tx: calls } = client.getUnsafeApi();
const call = calls.Utility!.batch_all!({
calls: [
calls.Balances!.transfer_keep_alive!({ dest: { type: 'Id', value: tx.dest }, value: tx.amount }).decodedCall,
calls.System!.remark_with_event!({ remark: Binary.fromText(tx.remark) }).decodedCall,
],
});
on('signing');
await new Promise<void>((resolve, reject) => {
let timer: ReturnType<typeof setTimeout> | undefined;
const sub = call.createSubmitAndWatch(signer.txCreator).subscribe({
next: (ev) => {
if (ev.type === 'broadcasted') {
on('sent');
// Once sent, a block takes 6 seconds: two minutes without one means the node lost it.
timer ??= setTimeout(() => {
sub.unsubscribe();
reject(new WalletError('node'));
}, 120_000);
}
if (ev.type === 'inBestBlock') {
clearTimeout(timer);
sub.unsubscribe();
if (ev.ok) {
on('included');
resolve();
} else reject(new WalletError(failure(ev.dispatchError)));
}
},
error: (e: unknown) => {
clearTimeout(timer);
reject(new WalletError(failure(e)));
},
});
});
} finally {
client.destroy();
ext.disconnect();
}
}
function injected(name: string, chain: ChainSettings): Wallet {
return {
id: name,
async accounts() {
const ext = await enable(name);
const list = await ext.accounts.get().catch((e: unknown) => {
throw new WalletError(failure(e));
});
return list.map((a) => ({ address: a.address, name: a.name ?? null }));
},
send(account, tx, on) {
return chain.test && chain.testNode
? sendTest(name, chain.testNode, account, tx, on)
: sendChain(name, chain.nodes, account, tx, on);
},
};
}
/** The wallets this page can talk to, the ones for Ğ1 first. */
export function wallets(chain: ChainSettings): Wallet[] {
return injectedNames(window as Page).map((name) => injected(name, chain));
}
+128 -27
View File
@@ -1,47 +1,88 @@
<script lang="ts">
import { goto } from '$app/navigation';
import { page } from '$app/state';
import ChainSign from '$lib/components/ChainSign.svelte';
import DeepList from '$lib/components/DeepList.svelte';
import DocPanel from '$lib/components/DocPanel.svelte';
import Guard from '$lib/components/Guard.svelte';
import Icon from '$lib/components/Icon.svelte';
import UnitBlock from '$lib/components/UnitBlock.svelte';
import VoteFold from '$lib/components/VoteFold.svelte';
import WaveRule from '$lib/components/WaveRule.svelte';
import { sections } from '$lib/basket';
import { DocState, shown, type PanelState, type Tab, type View } from '$lib/doc.svelte';
import { i18n, LANGS, t } from '$lib/i18n/index.svelte';
import { BasketState } from '$lib/picks.svelte';
import type { Lang, Proposal, Unit } from '$lib/types';
import type { Line } from '$lib/vote';
import { articles, type Filter } from '$lib/versions';
const doc = new DocState();
const basket = new BasketState();
const group = $derived(page.params.group ?? '');
const slug = $derived(page.params.doc ?? '');
$effect(() => {
void doc.load(group, slug);
});
// The basket of this document, kept for the tab; versions no longer open to vote leave it.
$effect(() => {
const v = doc.view;
if (!v) return;
const known = new Set(sections(v.units).flatMap((s) => s.slots.flatMap((x) => [...x.versions, ...x.inertia].map((p) => p.id))));
basket.load(v.doc.id, known);
});
// The panel lives in the URL: a link (or a feedback report) reopens it.
const TABS: Record<string, Tab> = { vote: 'vote', propositions: 'proposals', proposer: 'propose', reglages: 'settings' };
const TAB_NAMES: Record<Tab, string> = { vote: 'vote', proposals: 'propositions', propose: 'proposer', settings: 'reglages' };
// The panel and the unfolded vote live in the URL: a link (or a feedback report) reopens them.
const TABS: Record<string, Tab> = { panier: 'basket', propositions: 'proposals', proposer: 'propose', details: 'details', reglages: 'settings' };
const TAB_NAMES: Record<Tab, string> = { basket: 'panier', proposals: 'propositions', propose: 'proposer', details: 'details', settings: 'reglages' };
let prefill = $state<PanelState['prefill']>(null);
const panel = $derived.by((): PanelState | null => {
const q = page.url.searchParams;
const tab = TABS[q.get('onglet') ?? ''];
if (!tab) return null;
return { unitId: q.get('article'), proposalId: q.get('version'), tab, draftId: q.get('brouillon'), prefill };
return { unitId: q.get('article'), proposalId: q.get('version'), tab, draftId: q.get('brouillon'), section: q.get('section'), prefill };
});
/** The version whose vote is unfolded under its article (`?vote=`; the old `onglet=vote` too). */
const vote = $derived.by(() => {
const q = page.url.searchParams;
return q.get('vote') ?? (q.get('onglet') === 'vote' ? q.get('version') : null);
});
function openPanel(next: PanelState | null) {
prefill = next?.prefill ?? null;
function go(nextPanel: PanelState | null, nextVote: string | null) {
prefill = nextPanel?.prefill ?? null;
const q = new URLSearchParams();
if (next) {
q.set('onglet', TAB_NAMES[next.tab]);
if (next.unitId) q.set('article', next.unitId);
if (next.proposalId) q.set('version', next.proposalId);
if (next.draftId) q.set('brouillon', next.draftId);
if (nextVote) q.set('vote', nextVote);
if (nextPanel) {
q.set('onglet', TAB_NAMES[nextPanel.tab]);
if (nextPanel.unitId) q.set('article', nextPanel.unitId);
if (nextPanel.proposalId) q.set('version', nextPanel.proposalId);
if (nextPanel.draftId) q.set('brouillon', nextPanel.draftId);
if (nextPanel.section) q.set('section', nextPanel.section);
}
const search = q.toString();
void goto(`${page.url.pathname}${search ? `?${search}` : ''}`, { replaceState: !!panel && !!next, noScroll: true, keepFocus: true });
void goto(`${page.url.pathname}${search ? `?${search}` : ''}`, {
replaceState: (!!panel && !!nextPanel) || (!nextPanel && !panel),
noScroll: true,
keepFocus: true,
});
}
const openPanel = (next: PanelState | null) => go(next, vote);
/** Unfolds (or folds) the vote of a version; from the panel, `close` closes it. */
const toggleVote = (id: string, close = false) => go(close ? null : panel, vote === id && !close ? null : id);
const openVote = (id: string, close: boolean) => go(close ? null : panel, id);
// On the Ğ1 chain: one signing at a time, over the page.
let signing = $state<Line[] | null>(null);
const sign = (lines: Line[]) => (signing = lines);
// While a ballot waits for the chain, the page looks again every ten seconds.
const waitingChain = $derived(
(doc.view?.units ?? []).some((u) => [u.inForce, ...u.proposals].some((p) => p?.myBallot?.status === 'pending')),
);
$effect(() => {
if (!waitingChain) return;
const every = setInterval(() => void doc.reload(), 10_000);
return () => clearInterval(every);
});
let filter = $state<Filter>('all');
const view = $derived(doc.view);
@@ -54,14 +95,37 @@
const rivalsOf = (u: Unit) => u.proposals.filter((p) => p.status === 'open' && p.kind === 'text').length;
const VIEWS: View[] = ['inforce', 'wellrated', 'all'];
function onUnit(u: Unit, tab: Tab) {
openPanel({ unitId: u.id, proposalId: tab === 'vote' ? (u.inForce?.id ?? null) : null, tab });
}
function onProposal(u: Unit, p: Proposal) {
openPanel({ unitId: u.id, proposalId: p.id, tab: 'vote' });
function onUnit(u: Unit, action: 'vote' | 'proposals' | 'propose', version: string | null = u.inForce?.id ?? null) {
if (action === 'vote') {
if (version) toggleVote(version);
return;
}
openPanel({ unitId: u.id, proposalId: null, tab: action });
}
// The text shows the vote of its own versions; the list below, that of the others.
const textIds = $derived(
new Set(
doc.mode === 'inforce' || !doc.preview
? doc.inForce.map((u) => u.inForce?.id)
: doc.preview.map((pu) => pu.proposalId ?? doc.unit(pu.unitId)?.inForce?.id),
),
);
const inText = $derived(!!vote && textIds.has(vote));
const canVote = $derived(!!view?.vote.open);
</script>
{#snippet foldOf(unit: Unit, p: Proposal)}
<VoteFold
{doc}
proposal={p}
{unit}
onopen={(id) => go(panel, id)}
onsettings={(id) => openPanel({ unitId: unit.id, proposalId: id, tab: 'settings' })}
ondetails={(id) => openPanel({ unitId: unit.id, proposalId: id, tab: 'details' })}
onsign={sign}
/>
{/snippet}
<svelte:head><title>{title ? `${title} · libreDecision` : 'libreDecision'}</title></svelte:head>
{#if doc.error}
@@ -80,6 +144,13 @@
· <button class="linkish" type="button" onclick={() => openPanel({ unitId: null, proposalId: null, tab: 'settings' })}>{t('settings.title')}</button>
</p>
{#if canVote}
<div class="basket-row">
<button class="btn small basket-btn" type="button" onclick={() => openPanel({ unitId: null, proposalId: null, tab: 'basket' })}>
{basket.size ? t('doc.basket.count', { n: basket.size }) : t('doc.basket')}
</button>
</div>
{/if}
<div class="controls">
<div class="seg" role="radiogroup" aria-label={t('doc.view.label')}>
{#each VIEWS as v (v)}
@@ -102,14 +173,19 @@
{#if doc.mode === 'inforce' || !doc.preview}
{#each doc.inForce as u (u.id)}
{#if u.inForce}
{@const p = u.inForce}
<UnitBlock
text={shown(u.inForce, lang, source)}
seedMinimal={u.inForce.seedMinimal}
enteredAt={u.inForce.enteredAt}
text={shown(p, lang, source)}
seedMinimal={p.seedMinimal}
enteredAt={p.enteredAt}
rivals={rivalsOf(u)}
selected={panel?.unitId === u.id}
onaction={(tab) => onUnit(u, tab)}
/>
voteFor={p.id}
voteOpen={inText && vote === p.id}
onaction={(action) => onUnit(u, action)}
>
{#snippet fold()}{@render foldOf(u, p)}{/snippet}
</UnitBlock>
{/if}
{:else}
<p class="muted">{t('doc.empty')}</p>
@@ -117,12 +193,18 @@
{:else}
{#each doc.preview as pu (pu.unitId)}
{@const u = doc.unit(pu.unitId)}
{@const id = pu.proposalId ?? u?.inForce?.id ?? null}
{@const p = doc.proposal(id)}
<UnitBlock
text={shown(pu, lang, source)}
mark={pu.mark}
selected={panel?.unitId === pu.unitId}
onaction={(tab) => u && (pu.proposalId ? openPanel({ unitId: u.id, proposalId: pu.proposalId, tab }) : onUnit(u, tab))}
/>
voteFor={id}
voteOpen={inText && !!id && vote === id}
onaction={(action) => u && onUnit(u, action, id)}
>
{#snippet fold()}{#if u && p}{@render foldOf(u, p)}{/if}{/snippet}
</UnitBlock>
{/each}
{#if doc.preview.every((x) => x.mark === 'unchanged')}<p class="faint">{t('doc.view.empty')}</p>{/if}
{/if}
@@ -152,17 +234,26 @@
{/each}
</div>
</div>
<DeepList articles={deep} selected={panel?.proposalId ?? null} onopen={onProposal} />
<DeepList
articles={deep}
vote={inText ? null : vote}
ontoggle={(_, p) => toggleVote(p.id)}
fold={foldOf}
onsection={canVote ? (section) => openPanel({ unitId: null, proposalId: null, tab: 'basket', section }) : undefined}
/>
<button class="btn new" type="button" onclick={() => openPanel({ unitId: null, proposalId: null, tab: 'propose' })}><Icon name="plus" size={16} />{t('deep.new')}</button>
</section>
</div>
{#if panel}
<DocPanel {doc} {panel} {lang} onchange={(p) => openPanel(p)} onclose={() => openPanel(null)} />
<DocPanel {doc} {panel} {lang} {basket} {vote} onchange={(p) => openPanel(p)} onclose={() => openPanel(null)} onvote={openVote} onsign={sign} />
{/if}
{#if signing && view.vote.chain}
<ChainSign {doc} chain={view.vote.chain} lines={signing} onsent={() => basket.clear()} onclose={() => (signing = null)} />
{/if}
{/if}
<svelte:window onkeydown={(e) => e.key === 'Escape' && panel && openPanel(null)} />
<svelte:window onkeydown={(e) => e.key === 'Escape' && panel && !signing && openPanel(null)} />
<style>
.reader {
@@ -217,6 +308,16 @@
justify-content: space-between;
margin-top: 1.6rem;
}
.basket-row {
margin-top: 1.4rem;
}
.basket-row + .controls {
margin-top: 0.8rem;
}
.basket-btn {
background: var(--water);
color: var(--on-water);
}
.seg {
display: inline-flex;
flex-wrap: wrap;
+75
View File
@@ -0,0 +1,75 @@
import { describe, expect, it } from 'vitest';
import { clearScope, counts, fill, lines, sections, waiting } from '../../src/lib/basket';
import type { Proposal, Unit } from '../../src/lib/types';
function prop(id: string, unitId: string, over: Partial<Proposal> = {}): Proposal {
return {
id, unitId, kind: 'text', inertiaTarget: null, title: unitId, body: `texte ${id}`, status: 'open', origin: 'seed',
label: null, author: null, mine: false, revision: 1, createdAt: '', updatedAt: '', submittedAt: null, enteredAt: null,
confirmedAt: null, seedMinimal: false, tally: { for: 0, against: 0 }, evaluation: null, votedAtInertia: 3,
blocked: false, signals: 0, myVote: null, myVoteStale: false, myVoteChain: false, myBallot: null, translations: {},
...over,
};
}
function unit(id: string, section: string | null, inForce: Proposal | null, proposals: Proposal[]): Unit {
return { id, code: id, section, position: 0, inertia: 3, inertiaChanges: [], inForce, proposals };
}
// The certification act in small: a text in force, a seed with a rival and a removal, two
// open articles, one with two variants, a change of inertia.
const I1 = unit('I1', 'introduction', prop('i1', 'I1', { status: 'in_force' }), []);
const E1 = unit('E1', 'fondamental', prop('e1', 'E1', { status: 'in_force' }), [
prop('e1r', 'E1'),
prop('e1x', 'E1', { kind: 'remove' }),
prop('e1k', 'E1', { kind: 'inertia', inertiaTarget: 5 }),
]);
const E2 = unit('E2', 'fondamental', prop('e2', 'E2', { status: 'in_force' }), [prop('e2k', 'E2', { kind: 'inertia', inertiaTarget: 5 })]);
const E3 = unit('E3', 'technique', null, [prop('e3a', 'E3', { label: '1.0.0' }), prop('e3b', 'E3', { label: '0.4.0' })]);
const E4 = unit('E4', 'technique', null, [prop('e4', 'E4'), prop('e4d', 'E4', { status: 'draft' })]);
const EMPTY = unit('E9', 'technique', null, []);
const DOC = [I1, E1, E2, E3, E4, EMPTY];
describe('the basket of a vote', () => {
it('sections in the order of the document; an article holds its versions in competition', () => {
const s = sections(DOC);
expect(s.map((x) => x.key)).toEqual(['introduction', 'fondamental', 'technique']);
const [, fond, tech] = s;
// The text in force, its rival and its removal compete; a change of inertia is apart.
expect(fond?.slots[0]?.versions.map((p) => p.id)).toEqual(['e1', 'e1r', 'e1x']);
expect(fond?.slots[0]?.inertia.map((p) => p.id)).toEqual(['e1k']);
expect(fond?.slots[1]?.versions.map((p) => p.id)).toEqual(['e2']);
// Drafts are nobody's to vote; an empty slot is left out.
expect(tech?.slots.map((x) => x.unit.id)).toEqual(['E3', 'E4']);
expect(tech?.slots[1]?.versions.map((p) => p.id)).toEqual(['e4']);
});
it('a grouped gesture fills the articles of a single version, never one with several', () => {
const all = sections(DOC).flatMap((x) => x.slots);
const picks = fill(all, 'for', {});
expect(picks).toEqual({ i1: 'for', e2: 'for', e4: 'for' });
// Not the change of inertia of E2, not E1 (three versions), not E3 (two variants).
expect(waiting(all, picks).map((x) => x.unit.id)).toEqual(['E1', 'E3']);
// Choosing a version settles the article.
const chosen = { ...picks, e3b: 'against' as const };
expect(waiting(all, chosen).map((x) => x.unit.id)).toEqual(['E1']);
// A section only: the others stay as they were.
const tech = sections(DOC)[2]?.slots ?? [];
expect(fill(tech, 'against', { i1: 'for', e3a: 'for' })).toEqual({ i1: 'for', e3a: 'for', e4: 'against' });
});
it('emptying a section leaves the rest', () => {
const fond = sections(DOC)[1]?.slots ?? [];
expect(clearScope(fond, { i1: 'for', e2: 'for', e1k: 'against', e4: 'none' })).toEqual({ i1: 'for', e4: 'none' });
});
it('the lines sent: a withdrawal is a null choice; a version gone from the document is left out', () => {
const known = new Set(['i1', 'e2', 'e4']);
expect(lines({ i1: 'for', e2: 'none', e4: 'against', gone: 'for' }, known)).toEqual([
{ proposalId: 'i1', choice: 'for' },
{ proposalId: 'e2', choice: null },
{ proposalId: 'e4', choice: 'against' },
]);
expect(counts({ i1: 'for', e2: 'none', e4: 'against', e5: 'for' })).toEqual({ for: 2, against: 1, none: 1, total: 4 });
});
});
+2
View File
@@ -11,6 +11,8 @@ import { fr } from '../../src/lib/i18n/fr';
export const FORBIDDEN_UI_TERMS = [
'déléguer', 'délégation', 'délégué', 'dette', 'retard', 'en souffrance', 'corpus', 'snapshot',
'verdict', 'triage', 'oligarchie', 'peuple adulte',
// The Ğ1 chain and its wallets, in plain words (lot B): never the jargon.
'blockchain', 'extrinsic', 'extrinsèque',
];
const fold = (s: string) => s.normalize('NFD').replace(/[̀-ͯ]/g, '').toLowerCase();
+1 -1
View File
@@ -8,7 +8,7 @@ function prop(id: string, unitId: string, over: Partial<Proposal> = {}): Proposa
label: null, author: null, mine: false, revision: 1, createdAt: '', updatedAt: '', submittedAt: null, enteredAt: null,
confirmedAt: null, seedMinimal: false, tally: { for: 0, against: 0 },
evaluation: { cast: 0, required: 50, reached: false, missing: 50, requiredRatio: 1, wellRated: false },
votedAtInertia: 3, blocked: false, signals: 0, myVote: null, myVoteStale: false, translations: {},
votedAtInertia: 3, blocked: false, signals: 0, myVote: null, myVoteStale: false, myVoteChain: false, myBallot: null, translations: {},
...over,
};
}
+41
View File
@@ -0,0 +1,41 @@
import { describe, expect, it } from 'vitest';
import { amountText, geckoLink } from '../../src/lib/wallet/gecko';
import { failure, injectedNames, testExtrinsic } from '../../src/lib/wallet/injected';
const ACCOUNT = 'g1L5EYvvSBaYuQCPW78bHxYcvHfmWAqHYe3Gsrp9SV4kWZaEM';
const COMMENT = `ld1:${'ab'.repeat(32)}`;
describe('Ğecko', () => {
it('the link and the QR code carry the account, the amount in Ğ1 and the sealed comment', () => {
expect(geckoLink(ACCOUNT, 10, COMMENT)).toBe(`g1://${ACCOUNT}?amount=0.1&comment=${COMMENT}`);
// Ğ1nkgo keeps [A-Za-z0-9 .;:!?()-_@&<>%]: the comment passes untouched.
expect(/^[A-Za-z0-9 .;:!?()\-_@&<>%]+$/.test(COMMENT)).toBe(true);
});
it('amounts in cents read as Ğ1', () => {
expect([amountText(10), amountText(100), amountText(125), amountText(1)]).toEqual(['0.1', '1', '1.25', '0.01']);
});
});
describe('wallets of the page', () => {
it('every extension offering the standard interface is found, the one for Ğ1 first', () => {
const win = { injectedWeb3: { 'polkadot-js': { enable: async () => ({}) }, 'duniter-connect': { enable: async () => ({}) }, broken: {} } };
expect(injectedNames(win)).toEqual(['duniter-connect', 'polkadot-js']);
expect(injectedNames({})).toEqual([]);
});
it('what a wallet answers is told in words', () => {
expect(failure(new Error('Cancelled'))).toBe('cancelled');
expect(failure(new Error('Rejected by user'))).toBe('cancelled');
expect(failure({ type: 'Invalid', value: { type: 'Payment' } })).toBe('funds');
expect(failure(new Error('Inability to pay some fees (e.g. account balance too low)'))).toBe('funds');
expect(failure(new Error('socket hang up'))).toBe('wallet');
});
it('in tests, the fake node receives what was signed, readable', () => {
const hex = testExtrinsic({ address: '5Grw', dest: ACCOUNT, amount: '10', remark: COMMENT, signature: '0x01' });
expect(hex.startsWith('0x')).toBe(true);
const back = JSON.parse(new TextDecoder().decode(Uint8Array.from((hex.slice(2).match(/../g) ?? []).map((b) => parseInt(b, 16)))));
expect(back).toEqual({ address: '5Grw', dest: ACCOUNT, amount: '10', remark: COMMENT, signature: '0x01' });
});
});