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:
@@ -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
@@ -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",
|
||||
|
||||
@@ -7,6 +7,7 @@ rust-version.workspace = true
|
||||
publish.workspace = true
|
||||
|
||||
[dependencies]
|
||||
blake2 = "0.10"
|
||||
serde = { version = "1", features = ["derive"] }
|
||||
unicode-normalization = "0.1"
|
||||
|
||||
|
||||
@@ -0,0 +1,160 @@
|
||||
//! Ğ1 account addresses (SS58).
|
||||
//!
|
||||
//! The same public key reads `g1…` on the Ğ1 chain (prefix 4450, what the
|
||||
//! indexer and Ğecko show) and `5…` in the generic Substrate form (what
|
||||
//! duniter-connect shows). Accounts are compared by key, never by text.
|
||||
|
||||
/// The SS58 prefix of the Ğ1 chain.
|
||||
pub const G1_PREFIX: u16 = 4450;
|
||||
|
||||
const ALPHABET: &[u8; 58] = b"123456789ABCDEFGHJKLMNPQRSTUVWXYZabcdefghijkmnopqrstuvwxyz";
|
||||
|
||||
fn base58_decode(s: &str) -> Option<Vec<u8>> {
|
||||
// Big-endian bytes of the number, grown symbol by symbol.
|
||||
let mut number: Vec<u8> = Vec::new();
|
||||
for c in s.bytes() {
|
||||
let mut carry = u32::try_from(ALPHABET.iter().position(|a| *a == c)?).ok()?;
|
||||
for b in number.iter_mut().rev() {
|
||||
carry += u32::from(*b) * 58;
|
||||
*b = (carry & 0xff) as u8;
|
||||
carry >>= 8;
|
||||
}
|
||||
while carry > 0 {
|
||||
number.insert(0, (carry & 0xff) as u8);
|
||||
carry >>= 8;
|
||||
}
|
||||
}
|
||||
let mut out = vec![0u8; s.bytes().take_while(|c| *c == b'1').count()];
|
||||
out.extend(number);
|
||||
Some(out)
|
||||
}
|
||||
|
||||
fn base58_encode(data: &[u8]) -> String {
|
||||
// Little-endian base-58 symbols.
|
||||
let mut symbols: Vec<u8> = Vec::new();
|
||||
for byte in data {
|
||||
let mut carry = u32::from(*byte);
|
||||
for d in symbols.iter_mut() {
|
||||
carry += u32::from(*d) << 8;
|
||||
*d = (carry % 58) as u8;
|
||||
carry /= 58;
|
||||
}
|
||||
while carry > 0 {
|
||||
symbols.push((carry % 58) as u8);
|
||||
carry /= 58;
|
||||
}
|
||||
}
|
||||
let zeros = data.iter().take_while(|b| **b == 0).count();
|
||||
std::iter::repeat_n('1', zeros).chain(symbols.iter().rev().map(|d| char::from(ALPHABET[usize::from(*d)]))).collect()
|
||||
}
|
||||
|
||||
/// The two bytes that close an address: blake2b-512 of `SS58PRE`, the prefix and the key.
|
||||
fn checksum(payload: &[u8]) -> [u8; 2] {
|
||||
use blake2::{Blake2b512, Digest};
|
||||
let mut h = Blake2b512::new();
|
||||
h.update(b"SS58PRE");
|
||||
h.update(payload);
|
||||
let out = h.finalize();
|
||||
[out[0], out[1]]
|
||||
}
|
||||
|
||||
fn prefix_bytes(prefix: u16) -> Vec<u8> {
|
||||
if prefix < 64 {
|
||||
vec![prefix as u8]
|
||||
} else {
|
||||
// Two bytes for the prefixes 64 to 16383, as Substrate writes them.
|
||||
vec![
|
||||
(((prefix & 0b1111_1100) >> 2) as u8) | 0b0100_0000,
|
||||
((prefix >> 8) as u8) | (((prefix & 0b11) as u8) << 6),
|
||||
]
|
||||
}
|
||||
}
|
||||
|
||||
/// The prefix and the 32-byte public key of an SS58 address, if it is one.
|
||||
pub fn decode(addr: &str) -> Option<(u16, [u8; 32])> {
|
||||
let data = base58_decode(addr)?;
|
||||
let (prefix, at) = match *data.first()? {
|
||||
b @ 0..=63 => (u16::from(b), 1),
|
||||
b @ 64..=127 => {
|
||||
let b1 = *data.get(1)?;
|
||||
let lower = ((b & 0b0011_1111) << 2) | (b1 >> 6);
|
||||
let upper = b1 & 0b0011_1111;
|
||||
(u16::from(lower) | (u16::from(upper) << 8), 2)
|
||||
}
|
||||
_ => return None,
|
||||
};
|
||||
if data.len() != at + 32 + 2 {
|
||||
return None;
|
||||
}
|
||||
let (payload, sum) = data.split_at(at + 32);
|
||||
if checksum(payload) != sum {
|
||||
return None;
|
||||
}
|
||||
Some((prefix, payload[at..].try_into().ok()?))
|
||||
}
|
||||
|
||||
/// The SS58 address of a public key under a prefix (up to 16383).
|
||||
pub fn encode(prefix: u16, key: &[u8; 32]) -> String {
|
||||
let mut data = prefix_bytes(prefix & 0x3fff);
|
||||
data.extend_from_slice(key);
|
||||
let sum = checksum(&data);
|
||||
data.extend_from_slice(&sum);
|
||||
base58_encode(&data)
|
||||
}
|
||||
|
||||
/// The Ğ1 form of an address in any SS58 form.
|
||||
pub fn to_g1(addr: &str) -> Option<String> {
|
||||
decode(addr).map(|(_, key)| encode(G1_PREFIX, &key))
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
fn hex32(s: &str) -> [u8; 32] {
|
||||
let bytes: Vec<u8> = (0..64).step_by(2).map(|i| u8::from_str_radix(&s[i..i + 2], 16).expect("hex")).collect();
|
||||
bytes.try_into().expect("32 bytes")
|
||||
}
|
||||
|
||||
// Computed with @polkadot/util-crypto (decodeAddress / encodeAddress), 2026-10-30.
|
||||
const VECTORS: [(&str, &str, &str); 3] = [
|
||||
(
|
||||
"g1NrqKgJW9FoFabaYW82zhS9tDWdQcL1DGpivaJdj7GW9M6iv",
|
||||
"aba6b0dcf21a0ddff2e87b52225ddddf06e8c83afb7cd874e7438dd9aa7c2ee8",
|
||||
"5FwmZc7BSxiLyBDCuB6U6oW9sQNmXk5vNRRYEy8qXxo2FAkn",
|
||||
),
|
||||
(
|
||||
"g1LQM6MfnjdPTrgz3ubwhwnPdbWUUq2GGQCqRQuGgrke9vRMA",
|
||||
"3efad05b01361fe102ce8bff5e5fd1be7554af684c7e741aaab256cdf950b196",
|
||||
"5DVHLHUU3LJZFGciJf1BM9juFQDqkSLyVoY35ZmoHSw2pZ1s",
|
||||
),
|
||||
(
|
||||
"g1Pn1geRZnHcVdWdKkoYwQrAARDTXK1D7H3zuXj7GNayT1thu",
|
||||
"d43593c715fdd31c61141abd04a99fd6822c8558854ccde39a5684e7a56da27d",
|
||||
"5GrwvaEF5zXb26Fz9rcQpDWS57CtERHpNehXCPcNoHGKutQY",
|
||||
),
|
||||
];
|
||||
|
||||
#[test]
|
||||
fn g1_and_generic_forms_of_the_same_key() {
|
||||
for (g1, key, generic) in VECTORS {
|
||||
assert_eq!(decode(g1), Some((G1_PREFIX, hex32(key))), "{g1}");
|
||||
assert_eq!(decode(generic), Some((42, hex32(key))), "{generic}");
|
||||
assert_eq!(encode(G1_PREFIX, &hex32(key)), g1);
|
||||
assert_eq!(encode(42, &hex32(key)), generic);
|
||||
assert_eq!(to_g1(generic).as_deref(), Some(g1));
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn what_is_not_an_address() {
|
||||
let (g1, _, _) = VECTORS[0];
|
||||
// One character changed: the checksum no longer holds.
|
||||
let mut tampered = g1.to_owned();
|
||||
tampered.replace_range(10..11, if &g1[10..11] == "a" { "b" } else { "a" });
|
||||
for bad in ["", "g1", "0OIl", &tampered, &g1[..g1.len() - 1], "5GrwvaEF5zXb26Fz9rcQpDWS57CtERHpNehXCPcNoHGKutQ"]
|
||||
{
|
||||
assert_eq!(decode(bad), None, "{bad}");
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,432 @@
|
||||
//! The sealed ballot of a vote on the Ğ1 chain (ADR 0008).
|
||||
//!
|
||||
//! Yvv, 2026-10-30: « une personne une voix, mais la voix peut s'exprimer
|
||||
//! plusieurs fois […] c'est le dernier qui fait foi. Pour garantir l'intégrité
|
||||
//! d'un vote, je crois qu'il n'y a que la blockchain, donc une transaction par
|
||||
//! vote. » And: the other votes are seen as totals only.
|
||||
//!
|
||||
//! So a vote is one transaction, signed by the person's wallet: a transfer of
|
||||
//! 0.1 Ğ1 to the vote account and, in the same transaction, a remark that seals
|
||||
//! the ballot — `ld1:` followed by the 64 hex digits of
|
||||
//! `H = blake2b-256(canonical ballot)`. The canonical ballot names the document,
|
||||
//! the account bound to sign it (when known), every line (proposal, revision,
|
||||
//! choice) and a random salt of 128 bits. The chain shows who voted and when,
|
||||
//! never what: without the salt, a choice among three could be guessed.
|
||||
//!
|
||||
//! The chain also orders the votes: **the last one stands**, per proposal and
|
||||
//! per identity, in the order of the chain — block, then rank of the event in
|
||||
//! the block. A ballot confirmed late never overrides a more recent one.
|
||||
//!
|
||||
//! Pure: the salt comes from the caller, the facts of the chain too.
|
||||
|
||||
use crate::address::{self, G1_PREFIX};
|
||||
use blake2::digest::consts::U32;
|
||||
use blake2::{Blake2b, Digest};
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
/// What every sealed remark starts with: the version of this format.
|
||||
pub const PREFIX: &str = "ld1:";
|
||||
/// A salt is 16 bytes, written as 32 hex digits.
|
||||
pub const SALT_HEX: usize = 32;
|
||||
|
||||
/// blake2b with a 256-bit output — the `BlakeTwo256` of Substrate, which hashes a
|
||||
/// remark in the `System.Remarked` event, and the one the indexer keys comments on.
|
||||
pub fn blake2_256(data: &[u8]) -> [u8; 32] {
|
||||
Blake2b::<U32>::digest(data).into()
|
||||
}
|
||||
|
||||
pub fn to_hex(bytes: &[u8]) -> String {
|
||||
bytes.iter().map(|b| format!("{b:02x}")).collect()
|
||||
}
|
||||
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "lowercase")]
|
||||
pub enum Choice {
|
||||
For,
|
||||
Against,
|
||||
/// No vote: withdraws an earlier one.
|
||||
None,
|
||||
}
|
||||
|
||||
impl Choice {
|
||||
pub fn as_str(self) -> &'static str {
|
||||
match self {
|
||||
Self::For => "for",
|
||||
Self::Against => "against",
|
||||
Self::None => "none",
|
||||
}
|
||||
}
|
||||
|
||||
pub fn parse(raw: &str) -> Option<Self> {
|
||||
[Self::For, Self::Against, Self::None].into_iter().find(|c| c.as_str() == raw)
|
||||
}
|
||||
}
|
||||
|
||||
/// One line of a ballot: a version of an article, the revision voted, the choice.
|
||||
#[derive(Clone, Debug, PartialEq, Eq)]
|
||||
pub struct Line {
|
||||
pub proposal_id: String,
|
||||
pub revision: u32,
|
||||
pub choice: Choice,
|
||||
}
|
||||
|
||||
#[derive(Debug, PartialEq, Eq)]
|
||||
pub enum BallotError {
|
||||
/// No line, or the same proposal twice.
|
||||
Lines,
|
||||
/// An identifier that would not read back (empty, spaces, line breaks).
|
||||
Identifier,
|
||||
/// The salt is not 32 hex digits.
|
||||
Salt,
|
||||
/// The bound account is not an SS58 address.
|
||||
Author,
|
||||
}
|
||||
|
||||
/// The ballot as it is hashed: one field per line, the lines in the order of
|
||||
/// their proposal identifiers, the author as a Ğ1 address (or `-`).
|
||||
pub fn canonical(doc_id: &str, author: Option<&str>, lines: &[Line], salt_hex: &str) -> Result<String, BallotError> {
|
||||
let plain = |s: &str| !s.is_empty() && s.chars().all(|c| c.is_ascii_graphic());
|
||||
if !plain(doc_id) || lines.iter().any(|l| !plain(&l.proposal_id)) {
|
||||
return Err(BallotError::Identifier);
|
||||
}
|
||||
if salt_hex.len() != SALT_HEX || !salt_hex.bytes().all(|b| b.is_ascii_hexdigit() && !b.is_ascii_uppercase()) {
|
||||
return Err(BallotError::Salt);
|
||||
}
|
||||
let author = match author {
|
||||
Some(a) => address::decode(a).map(|(_, key)| address::encode(G1_PREFIX, &key)).ok_or(BallotError::Author)?,
|
||||
None => "-".to_owned(),
|
||||
};
|
||||
let mut sorted: Vec<&Line> = lines.iter().collect();
|
||||
sorted.sort_by(|a, b| a.proposal_id.cmp(&b.proposal_id));
|
||||
if sorted.is_empty() || sorted.windows(2).any(|w| w[0].proposal_id == w[1].proposal_id) {
|
||||
return Err(BallotError::Lines);
|
||||
}
|
||||
let mut out = format!("ld1\ndoc {doc_id}\nauthor {author}\n");
|
||||
for l in sorted {
|
||||
out.push_str(&format!("line {} {} {}\n", l.proposal_id, l.revision, l.choice.as_str()));
|
||||
}
|
||||
out.push_str(&format!("salt {salt_hex}\n"));
|
||||
Ok(out)
|
||||
}
|
||||
|
||||
/// The seal of a ballot, and what the chain sees of it.
|
||||
#[derive(Clone, Debug, PartialEq, Eq)]
|
||||
pub struct Sealed {
|
||||
/// H, 64 hex digits.
|
||||
pub digest: String,
|
||||
/// The remark of the transaction: `ld1:` + H, 68 ASCII bytes.
|
||||
pub comment: String,
|
||||
/// blake2b-256 of the remark's bytes, `0x` + 64 hex digits: what the
|
||||
/// indexer finds the comment by.
|
||||
pub remark_hash: String,
|
||||
}
|
||||
|
||||
pub fn seal(doc_id: &str, author: Option<&str>, lines: &[Line], salt_hex: &str) -> Result<Sealed, BallotError> {
|
||||
let text = canonical(doc_id, author, lines, salt_hex)?;
|
||||
let digest = to_hex(&blake2_256(text.as_bytes()));
|
||||
let comment = format!("{PREFIX}{digest}");
|
||||
let remark_hash = format!("0x{}", to_hex(&blake2_256(comment.as_bytes())));
|
||||
Ok(Sealed { digest, comment, remark_hash })
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// The order of the chain
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/// Where an event sits on the chain: its block, then its rank among the events
|
||||
/// of that block. The order of votes.
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize, Deserialize)]
|
||||
pub struct ChainPosition {
|
||||
pub block: u32,
|
||||
pub event: u32,
|
||||
}
|
||||
|
||||
impl ChainPosition {
|
||||
/// The identifier the indexer gives an event: `<block, 10 digits>-<5 hex of
|
||||
/// the block hash>-<rank of the event, 6 digits>`, e.g. `0003399566-d43e7-000001`.
|
||||
pub fn from_event_id(id: &str) -> Option<Self> {
|
||||
let mut parts = id.split('-');
|
||||
let (block, hash, event) = (parts.next()?, parts.next()?, parts.next()?);
|
||||
if parts.next().is_some() || hash.is_empty() || !hash.bytes().all(|b| b.is_ascii_hexdigit()) {
|
||||
return None;
|
||||
}
|
||||
let digits = |s: &str| !s.is_empty() && s.bytes().all(|b| b.is_ascii_digit());
|
||||
if !digits(block) || !digits(event) {
|
||||
return None;
|
||||
}
|
||||
Some(Self { block: block.parse().ok()?, event: event.parse().ok()? })
|
||||
}
|
||||
}
|
||||
|
||||
/// The last one stands: does a vote at `candidate` decide, given the positions of
|
||||
/// the other confirmed votes of the same identity on the same proposal? Only if
|
||||
/// it comes after all of them.
|
||||
pub fn decides(candidate: ChainPosition, others: &[ChainPosition]) -> bool {
|
||||
others.iter().all(|o| *o < candidate)
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// What the chain says of a comment
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/// A Ğ1 identity, as the chain has it now.
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||||
pub struct ChainIdentity {
|
||||
pub index: u32,
|
||||
pub member: bool,
|
||||
}
|
||||
|
||||
/// A transfer in the same transaction as the comment.
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||||
pub struct Transfer {
|
||||
pub from: [u8; 32],
|
||||
pub to: [u8; 32],
|
||||
/// In cents of Ğ1.
|
||||
pub amount: u64,
|
||||
}
|
||||
|
||||
/// A comment found on the chain with the remark of a ballot.
|
||||
#[derive(Clone, Debug, PartialEq, Eq)]
|
||||
pub struct Comment {
|
||||
pub position: ChainPosition,
|
||||
/// The account that signed the transaction.
|
||||
pub author: [u8; 32],
|
||||
/// The identity whose account it is, if any.
|
||||
pub identity: Option<ChainIdentity>,
|
||||
pub transfers: Vec<Transfer>,
|
||||
}
|
||||
|
||||
/// What a ballot asks of its transaction.
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||||
pub struct Expected {
|
||||
/// The vote account.
|
||||
pub account: [u8; 32],
|
||||
/// At least this many cents.
|
||||
pub amount: u64,
|
||||
/// The account bound to sign, when the page knew it.
|
||||
pub author: Option<[u8; 32]>,
|
||||
}
|
||||
|
||||
/// What the chain says of a ballot's transaction. Whether a paid ballot counts
|
||||
/// is an access rule — membership of the web of trust, now — that belongs to
|
||||
/// trustinWallet (`trustin_access::chain_vote_counts`).
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||||
pub enum Verdict {
|
||||
/// The transaction carries the transfer, and its signer is the account of
|
||||
/// this identity.
|
||||
Paid { identity: ChainIdentity },
|
||||
/// Signed by another account than the one bound: a copy of the remark, ignored.
|
||||
OtherAuthor,
|
||||
/// The account is not that of an identity.
|
||||
NoIdentity,
|
||||
/// No transfer of the amount to the vote account in the same transaction.
|
||||
NoTransfer,
|
||||
}
|
||||
|
||||
impl Verdict {
|
||||
/// Why a ballot is refused, as stored and translated.
|
||||
pub fn reason(self) -> Option<&'static str> {
|
||||
match self {
|
||||
Self::Paid { .. } | Self::OtherAuthor => None,
|
||||
Self::NoIdentity => Some("no_identity"),
|
||||
Self::NoTransfer => Some("transfer"),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub fn verdict(c: &Comment, e: &Expected) -> Verdict {
|
||||
if e.author.is_some_and(|a| a != c.author) {
|
||||
return Verdict::OtherAuthor;
|
||||
}
|
||||
let Some(identity) = c.identity else { return Verdict::NoIdentity };
|
||||
if !c.transfers.iter().any(|t| t.from == c.author && t.to == e.account && t.amount >= e.amount) {
|
||||
return Verdict::NoTransfer;
|
||||
}
|
||||
Verdict::Paid { identity }
|
||||
}
|
||||
|
||||
/// The comment that settles a ballot: the first in the order of the chain that is
|
||||
/// not a copy by another account. `None`: nothing yet, keep waiting.
|
||||
pub fn judge(comments: &[Comment], e: &Expected) -> Option<(ChainPosition, Verdict)> {
|
||||
let mut sorted: Vec<&Comment> = comments.iter().collect();
|
||||
sorted.sort_by_key(|c| c.position);
|
||||
sorted.into_iter().map(|c| (c.position, verdict(c, e))).find(|(_, v)| *v != Verdict::OtherAuthor)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
const ALICE: &str = "g1Pn1geRZnHcVdWdKkoYwQrAARDTXK1D7H3zuXj7GNayT1thu";
|
||||
const SIMONE: &str = "g1NrqKgJW9FoFabaYW82zhS9tDWdQcL1DGpivaJdj7GW9M6iv";
|
||||
|
||||
fn line(p: &str, revision: u32, choice: Choice) -> Line {
|
||||
Line { proposal_id: p.into(), revision, choice }
|
||||
}
|
||||
|
||||
fn key(addr: &str) -> [u8; 32] {
|
||||
address::decode(addr).expect("address").1
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_remark_hash_is_the_one_the_chain_publishes() {
|
||||
// Real comment « Merci » of the Ğ1 chain; its hash as the indexer serves it (2026-10-30).
|
||||
assert_eq!(to_hex(&blake2_256(b"Merci ")), "870dc594c5b4daabcc2a3c7149dc2af418376cde62d15cf28a3622fd900c87ce");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_canonical_ballot_and_its_seal() {
|
||||
let lines = [line("p2", 1, Choice::Against), line("p1", 3, Choice::For)];
|
||||
let salt = "00112233445566778899aabbccddeeff";
|
||||
assert_eq!(
|
||||
canonical("d0c", None, &lines, salt).expect("valid"),
|
||||
"ld1\ndoc d0c\nauthor -\nline p1 3 for\nline p2 1 against\nsalt 00112233445566778899aabbccddeeff\n"
|
||||
);
|
||||
// Computed apart (Python hashlib.blake2b, digest_size=32).
|
||||
let s = seal("d0c", None, &lines, salt).expect("valid");
|
||||
assert_eq!(s.digest, "20448d496744292981c8586856afb58a86e37da2470f764b4e6ed84eade71340");
|
||||
assert_eq!(s.comment, "ld1:20448d496744292981c8586856afb58a86e37da2470f764b4e6ed84eade71340");
|
||||
assert_eq!(s.comment.len(), 68);
|
||||
assert!(s.comment.is_ascii());
|
||||
assert_eq!(s.remark_hash, "0xe765221a75716dde863a7b53df3a97acf6db38a2118edd525d54c8a2979b7bc3");
|
||||
// The order of the lines does not matter; every field does.
|
||||
let swapped = [line("p1", 3, Choice::For), line("p2", 1, Choice::Against)];
|
||||
assert_eq!(seal("d0c", None, &swapped, salt).expect("valid"), s);
|
||||
for other in [
|
||||
seal("d0d", None, &lines, salt),
|
||||
seal("d0c", Some(ALICE), &lines, salt),
|
||||
seal("d0c", None, &[line("p2", 2, Choice::Against), line("p1", 3, Choice::For)], salt),
|
||||
seal("d0c", None, &[line("p2", 1, Choice::For), line("p1", 3, Choice::For)], salt),
|
||||
seal("d0c", None, &lines, "00112233445566778899aabbccddeef0"),
|
||||
] {
|
||||
assert_ne!(other.expect("valid").digest, s.digest);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_bound_author_is_written_as_a_g1_address_whatever_its_form() {
|
||||
let lines = [line("p1", 3, Choice::None)];
|
||||
let salt = "ffeeddccbbaa99887766554433221100";
|
||||
let s = seal("d0c", Some(SIMONE), &lines, salt).expect("valid");
|
||||
assert_eq!(s.digest, "6a6d8a90b5003ff3c4d050dbd36199e8bbf34c4cc11620618399226c9237c05e");
|
||||
assert_eq!(s.remark_hash, "0x846495fe62b9a5184a0d3090d4d26f621887c334890404169c00575ee74540e4");
|
||||
// The same key in the generic Substrate form (what duniter-connect shows).
|
||||
let generic = seal("d0c", Some("5FwmZc7BSxiLyBDCuB6U6oW9sQNmXk5vNRRYEy8qXxo2FAkn"), &lines, salt);
|
||||
assert_eq!(generic.expect("valid"), s);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn what_cannot_be_sealed() {
|
||||
let ok = [line("p1", 1, Choice::For)];
|
||||
let salt = "00112233445566778899aabbccddeeff";
|
||||
assert_eq!(seal("d0c", None, &[], salt), Err(BallotError::Lines));
|
||||
assert_eq!(
|
||||
seal("d0c", None, &[line("p1", 1, Choice::For), line("p1", 2, Choice::Against)], salt),
|
||||
Err(BallotError::Lines)
|
||||
);
|
||||
assert_eq!(seal("d 0c", None, &ok, salt), Err(BallotError::Identifier));
|
||||
assert_eq!(seal("d0c", None, &[line("p\n1", 1, Choice::For)], salt), Err(BallotError::Identifier));
|
||||
assert_eq!(seal("d0c", None, &ok, "0011"), Err(BallotError::Salt));
|
||||
assert_eq!(seal("d0c", None, &ok, "00112233445566778899AABBCCDDEEFF"), Err(BallotError::Salt));
|
||||
assert_eq!(seal("d0c", Some("g1Nope"), &ok, salt), Err(BallotError::Author));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn choices_read_back() {
|
||||
for c in [Choice::For, Choice::Against, Choice::None] {
|
||||
assert_eq!(Choice::parse(c.as_str()), Some(c));
|
||||
}
|
||||
assert_eq!(Choice::parse("abstain"), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_position_of_an_event_reads_in_its_identifier() {
|
||||
assert_eq!(
|
||||
ChainPosition::from_event_id("0003399566-d43e7-000001"),
|
||||
Some(ChainPosition { block: 3_399_566, event: 1 })
|
||||
);
|
||||
assert_eq!(
|
||||
ChainPosition::from_event_id("0000196944-60692-000003"),
|
||||
Some(ChainPosition { block: 196_944, event: 3 })
|
||||
);
|
||||
for bad in [
|
||||
"",
|
||||
"0003399566-d43e7",
|
||||
"0003399566-d43e7-000001-2",
|
||||
"x-d43e7-000001",
|
||||
"0003399566--000001",
|
||||
"0003399566-zz-000001",
|
||||
] {
|
||||
assert_eq!(ChainPosition::from_event_id(bad), None, "{bad}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_last_one_stands_in_the_order_of_the_chain() {
|
||||
let at = |block, event| ChainPosition { block, event };
|
||||
// Block first, then the rank of the event in the block.
|
||||
assert!(at(100, 9) < at(101, 0));
|
||||
assert!(at(100, 2) < at(100, 3));
|
||||
assert!(decides(at(101, 0), &[]));
|
||||
assert!(decides(at(101, 0), &[at(100, 9), at(100, 2)]));
|
||||
// Confirmed after a more recent one, an older ballot does not override it.
|
||||
assert!(!decides(at(100, 9), &[at(101, 0)]));
|
||||
assert!(!decides(at(100, 2), &[at(100, 3)]));
|
||||
}
|
||||
|
||||
fn comment(block: u32, author: &str, identity: Option<(u32, bool)>, transfers: &[(&str, &str, u64)]) -> Comment {
|
||||
Comment {
|
||||
position: ChainPosition { block, event: 1 },
|
||||
author: key(author),
|
||||
identity: identity.map(|(index, member)| ChainIdentity { index, member }),
|
||||
transfers: transfers
|
||||
.iter()
|
||||
.map(|(f, t, amount)| Transfer { from: key(f), to: key(t), amount: *amount })
|
||||
.collect(),
|
||||
}
|
||||
}
|
||||
|
||||
const SIMONE_MEMBER: ChainIdentity = ChainIdentity { index: 1304, member: true };
|
||||
|
||||
#[test]
|
||||
fn a_ballot_is_paid_by_the_account_of_an_identity() {
|
||||
let e = Expected { account: key(ALICE), amount: 10, author: None };
|
||||
let paid = [(SIMONE, ALICE, 10)];
|
||||
let ok = Verdict::Paid { identity: SIMONE_MEMBER };
|
||||
assert_eq!(verdict(&comment(1, SIMONE, Some((1304, true)), &paid), &e), ok);
|
||||
// More than asked is fine.
|
||||
assert_eq!(verdict(&comment(1, SIMONE, Some((1304, true)), &[(SIMONE, ALICE, 25)]), &e), ok);
|
||||
// Membership is not the engine's to decide (`trustin_access::chain_vote_counts`): it is passed on.
|
||||
let former = Verdict::Paid { identity: ChainIdentity { index: 1304, member: false } };
|
||||
assert_eq!(verdict(&comment(1, SIMONE, Some((1304, false)), &paid), &e), former);
|
||||
assert_eq!(verdict(&comment(1, SIMONE, None, &paid), &e), Verdict::NoIdentity);
|
||||
// Too little, elsewhere, or nothing.
|
||||
assert_eq!(verdict(&comment(1, SIMONE, Some((1304, true)), &[(SIMONE, ALICE, 9)]), &e), Verdict::NoTransfer);
|
||||
assert_eq!(verdict(&comment(1, SIMONE, Some((1304, true)), &[(SIMONE, SIMONE, 10)]), &e), Verdict::NoTransfer);
|
||||
assert_eq!(verdict(&comment(1, SIMONE, Some((1304, true)), &[]), &e), Verdict::NoTransfer);
|
||||
// Paid from another account than the signer's.
|
||||
assert_eq!(verdict(&comment(1, SIMONE, Some((1304, true)), &[(ALICE, ALICE, 10)]), &e), Verdict::NoTransfer);
|
||||
assert_eq!(Verdict::NoTransfer.reason(), Some("transfer"));
|
||||
assert_eq!(Verdict::NoIdentity.reason(), Some("no_identity"));
|
||||
assert_eq!(ok.reason(), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_copy_of_the_remark_by_another_account_is_ignored_when_the_author_is_bound() {
|
||||
let e = Expected { account: key(ALICE), amount: 10, author: Some(key(SIMONE)) };
|
||||
let copy = comment(10, ALICE, Some((1, true)), &[(ALICE, ALICE, 10)]);
|
||||
let own = comment(11, SIMONE, Some((1304, true)), &[(SIMONE, ALICE, 10)]);
|
||||
assert_eq!(verdict(©, &e), Verdict::OtherAuthor);
|
||||
// The copy came first on the chain: it is skipped, the ballot counts for its author.
|
||||
assert_eq!(
|
||||
judge(&[own.clone(), copy.clone()], &e),
|
||||
Some((own.position, Verdict::Paid { identity: SIMONE_MEMBER }))
|
||||
);
|
||||
assert_eq!(judge(&[copy], &e), None, "only a copy: keep waiting");
|
||||
// Unbound (a Ğecko code): the first on the chain settles it.
|
||||
let free = Expected { author: None, ..e };
|
||||
let early = comment(9, SIMONE, None, &[(SIMONE, ALICE, 10)]);
|
||||
assert_eq!(judge(&[own, early.clone()], &free), Some((early.position, Verdict::NoIdentity)));
|
||||
assert_eq!(judge(&[], &free), None);
|
||||
}
|
||||
}
|
||||
@@ -6,7 +6,9 @@
|
||||
//! separates a typo fix from a change of meaning, the rule-based editing
|
||||
//! assistant, and the composition of a document under permanent vote.
|
||||
|
||||
pub mod address;
|
||||
pub mod assist;
|
||||
pub mod ballot;
|
||||
pub mod compose;
|
||||
pub mod edit;
|
||||
pub mod inertia;
|
||||
|
||||
@@ -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;
|
||||
@@ -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 }))
|
||||
}
|
||||
@@ -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,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -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");
|
||||
}
|
||||
}
|
||||
@@ -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");
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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 })))
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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"
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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");
|
||||
|
||||
@@ -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]
|
||||
|
||||
@@ -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]
|
||||
|
||||
@@ -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.
|
||||
|
||||
|
||||
@@ -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).
|
||||
@@ -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 |
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 |
|
||||
|
||||
@@ -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
@@ -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');
|
||||
|
||||
@@ -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
@@ -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
@@ -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
@@ -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
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
Generated
+1503
File diff suppressed because it is too large
Load Diff
@@ -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"
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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} />
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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) {
|
||||
|
||||
+28
-110
@@ -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);
|
||||
@@ -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>
|
||||
@@ -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
@@ -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
@@ -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
@@ -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',
|
||||
|
||||
@@ -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
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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 };
|
||||
}
|
||||
@@ -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));
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
@@ -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 });
|
||||
});
|
||||
});
|
||||
@@ -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();
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -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' });
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user