diff --git a/CLAUDE.md b/CLAUDE.md index b8fca09..1f188c4 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -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/.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` ; diff --git a/Cargo.lock b/Cargo.lock index 198bc13..9f02d96 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -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", diff --git a/crates/ld-engine/Cargo.toml b/crates/ld-engine/Cargo.toml index 487b5fe..6eab03a 100644 --- a/crates/ld-engine/Cargo.toml +++ b/crates/ld-engine/Cargo.toml @@ -7,6 +7,7 @@ rust-version.workspace = true publish.workspace = true [dependencies] +blake2 = "0.10" serde = { version = "1", features = ["derive"] } unicode-normalization = "0.1" diff --git a/crates/ld-engine/src/address.rs b/crates/ld-engine/src/address.rs new file mode 100644 index 0000000..a8b3945 --- /dev/null +++ b/crates/ld-engine/src/address.rs @@ -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> { + // Big-endian bytes of the number, grown symbol by symbol. + let mut number: Vec = 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 = 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 { + 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 { + decode(addr).map(|(_, key)| encode(G1_PREFIX, &key)) +} + +#[cfg(test)] +mod tests { + use super::*; + + fn hex32(s: &str) -> [u8; 32] { + let bytes: Vec = (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}"); + } + } +} diff --git a/crates/ld-engine/src/ballot.rs b/crates/ld-engine/src/ballot.rs new file mode 100644 index 0000000..d7c9a94 --- /dev/null +++ b/crates/ld-engine/src/ballot.rs @@ -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::::digest(data).into() +} + +pub fn to_hex(bytes: &[u8]) -> String { + bytes.iter().map(|b| format!("{b:02x}")).collect() +} + +#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "lowercase")] +pub enum Choice { + For, + Against, + /// No vote: withdraws an earlier one. + None, +} + +impl Choice { + pub fn as_str(self) -> &'static str { + match self { + Self::For => "for", + Self::Against => "against", + Self::None => "none", + } + } + + pub fn parse(raw: &str) -> Option { + [Self::For, Self::Against, Self::None].into_iter().find(|c| c.as_str() == raw) + } +} + +/// One line of a ballot: a version of an article, the revision voted, the choice. +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct Line { + pub proposal_id: String, + pub revision: u32, + pub choice: Choice, +} + +#[derive(Debug, PartialEq, Eq)] +pub enum BallotError { + /// No line, or the same proposal twice. + Lines, + /// An identifier that would not read back (empty, spaces, line breaks). + Identifier, + /// The salt is not 32 hex digits. + Salt, + /// The bound account is not an SS58 address. + Author, +} + +/// The ballot as it is hashed: one field per line, the lines in the order of +/// their proposal identifiers, the author as a Ğ1 address (or `-`). +pub fn canonical(doc_id: &str, author: Option<&str>, lines: &[Line], salt_hex: &str) -> Result { + let plain = |s: &str| !s.is_empty() && s.chars().all(|c| c.is_ascii_graphic()); + if !plain(doc_id) || lines.iter().any(|l| !plain(&l.proposal_id)) { + return Err(BallotError::Identifier); + } + if salt_hex.len() != SALT_HEX || !salt_hex.bytes().all(|b| b.is_ascii_hexdigit() && !b.is_ascii_uppercase()) { + return Err(BallotError::Salt); + } + let author = match author { + Some(a) => address::decode(a).map(|(_, key)| address::encode(G1_PREFIX, &key)).ok_or(BallotError::Author)?, + None => "-".to_owned(), + }; + let mut sorted: Vec<&Line> = lines.iter().collect(); + sorted.sort_by(|a, b| a.proposal_id.cmp(&b.proposal_id)); + if sorted.is_empty() || sorted.windows(2).any(|w| w[0].proposal_id == w[1].proposal_id) { + return Err(BallotError::Lines); + } + let mut out = format!("ld1\ndoc {doc_id}\nauthor {author}\n"); + for l in sorted { + out.push_str(&format!("line {} {} {}\n", l.proposal_id, l.revision, l.choice.as_str())); + } + out.push_str(&format!("salt {salt_hex}\n")); + Ok(out) +} + +/// The seal of a ballot, and what the chain sees of it. +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct Sealed { + /// H, 64 hex digits. + pub digest: String, + /// The remark of the transaction: `ld1:` + H, 68 ASCII bytes. + pub comment: String, + /// blake2b-256 of the remark's bytes, `0x` + 64 hex digits: what the + /// indexer finds the comment by. + pub remark_hash: String, +} + +pub fn seal(doc_id: &str, author: Option<&str>, lines: &[Line], salt_hex: &str) -> Result { + let text = canonical(doc_id, author, lines, salt_hex)?; + let digest = to_hex(&blake2_256(text.as_bytes())); + let comment = format!("{PREFIX}{digest}"); + let remark_hash = format!("0x{}", to_hex(&blake2_256(comment.as_bytes()))); + Ok(Sealed { digest, comment, remark_hash }) +} + +// --------------------------------------------------------------------------- +// The order of the chain +// --------------------------------------------------------------------------- + +/// Where an event sits on the chain: its block, then its rank among the events +/// of that block. The order of votes. +#[derive(Clone, Copy, Debug, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize, Deserialize)] +pub struct ChainPosition { + pub block: u32, + pub event: u32, +} + +impl ChainPosition { + /// The identifier the indexer gives an event: `-<5 hex of + /// the block hash>-`, e.g. `0003399566-d43e7-000001`. + pub fn from_event_id(id: &str) -> Option { + let mut parts = id.split('-'); + let (block, hash, event) = (parts.next()?, parts.next()?, parts.next()?); + if parts.next().is_some() || hash.is_empty() || !hash.bytes().all(|b| b.is_ascii_hexdigit()) { + return None; + } + let digits = |s: &str| !s.is_empty() && s.bytes().all(|b| b.is_ascii_digit()); + if !digits(block) || !digits(event) { + return None; + } + Some(Self { block: block.parse().ok()?, event: event.parse().ok()? }) + } +} + +/// The last one stands: does a vote at `candidate` decide, given the positions of +/// the other confirmed votes of the same identity on the same proposal? Only if +/// it comes after all of them. +pub fn decides(candidate: ChainPosition, others: &[ChainPosition]) -> bool { + others.iter().all(|o| *o < candidate) +} + +// --------------------------------------------------------------------------- +// What the chain says of a comment +// --------------------------------------------------------------------------- + +/// A Ğ1 identity, as the chain has it now. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub struct ChainIdentity { + pub index: u32, + pub member: bool, +} + +/// A transfer in the same transaction as the comment. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub struct Transfer { + pub from: [u8; 32], + pub to: [u8; 32], + /// In cents of Ğ1. + pub amount: u64, +} + +/// A comment found on the chain with the remark of a ballot. +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct Comment { + pub position: ChainPosition, + /// The account that signed the transaction. + pub author: [u8; 32], + /// The identity whose account it is, if any. + pub identity: Option, + pub transfers: Vec, +} + +/// What a ballot asks of its transaction. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub struct Expected { + /// The vote account. + pub account: [u8; 32], + /// At least this many cents. + pub amount: u64, + /// The account bound to sign, when the page knew it. + pub author: Option<[u8; 32]>, +} + +/// What the chain says of a ballot's transaction. Whether a paid ballot counts +/// is an access rule — membership of the web of trust, now — that belongs to +/// trustinWallet (`trustin_access::chain_vote_counts`). +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum Verdict { + /// The transaction carries the transfer, and its signer is the account of + /// this identity. + Paid { identity: ChainIdentity }, + /// Signed by another account than the one bound: a copy of the remark, ignored. + OtherAuthor, + /// 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); + } +} diff --git a/crates/ld-engine/src/lib.rs b/crates/ld-engine/src/lib.rs index a41ac17..945d854 100644 --- a/crates/ld-engine/src/lib.rs +++ b/crates/ld-engine/src/lib.rs @@ -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; diff --git a/crates/ld-server/migrations/0006_ballots.sql b/crates/ld-server/migrations/0006_ballots.sql new file mode 100644 index 0000000..6a8b97d --- /dev/null +++ b/crates/ld-server/migrations/0006_ballots.sql @@ -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:`. 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:`: 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: 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; diff --git a/crates/ld-server/src/ballots.rs b/crates/ld-server/src/ballots.rs new file mode 100644 index 0000000..561b1f3 --- /dev/null +++ b/crates/ld-server/src/ballots.rs @@ -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:` 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, +} + +#[derive(Deserialize)] +pub struct VotesBody { + lines: Vec, +} + +#[derive(Deserialize)] +pub struct BallotBody { + lines: Vec, + /// The account that will sign, in any SS58 form, when the page knows it. + author: Option, +} + +/// 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 { + 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> { + 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, + User(user): User, + Path(doc_id): Path, + Json(b): Json, +) -> AppResult> { + 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, + pub digest: String, + pub remark_hash: String, + pub account: String, + pub amount: i64, + pub status: String, + pub reason: Option, + pub voter_id: Option, + pub block: Option, + pub entered: Option, + pub created_at: String, + pub expires_at: String, + pub checked_at: Option, +} + +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, + User(user): User, + Path(doc_id): Path, + Json(b): Json, +) -> AppResult> { + 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 = 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 { + 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, + User(user): User, + Path(id): Path, +) -> AppResult> { + 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, + User(user): User, + Path(id): Path, +) -> AppResult> { + 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 { + let voter: Option = 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 = 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 = 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 = 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 = 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 { + 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 { + 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> { + 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, + 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> { + #[derive(sqlx::FromRow)] + struct Row { + proposal_id: String, + id: String, + status: String, + reason: Option, + 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 = HashMap::new(); + let mut pending: HashMap = 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 { + 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 })) +} diff --git a/crates/ld-server/src/config.rs b/crates/ld-server/src/config.rs index a600286..cfa1cf5 100644 --- a/crates/ld-server/src/config.rs +++ b/crates/ld-server/src/config.rs @@ -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, + /// 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, +} + +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, test_node: Option) -> Option { + 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, pub wot_fallback: u32, pub build: String, + /// The vote on the chain; `None` = off (no `LD_VOTE_ACCOUNT`). + pub vote: Option, } fn var(name: &str) -> Option { @@ -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, } } } diff --git a/crates/ld-server/src/docs.rs b/crates/ld-server/src/docs.rs index 2c147b4..994ac0c 100644 --- a/crates/ld-server/src/docs.rs +++ b/crates/ld-server/src/docs.rs @@ -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>, 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 = 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), diff --git a/crates/ld-server/src/indexer.rs b/crates/ld-server/src/indexer.rs new file mode 100644 index 0000000..08e5d37 --- /dev/null +++ b/crates/ld-server/src/indexer.rs @@ -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, + #[serde(default)] + errors: Option, +} + +#[derive(Deserialize)] +#[serde(rename_all = "camelCase")] +struct Data { + tx_comments: Nodes, +} + +#[derive(Deserialize)] +struct Nodes { + nodes: Vec, +} + +#[derive(Deserialize)] +#[serde(rename_all = "camelCase")] +struct Node { + id: String, + block_number: u32, + remark: String, + author: Author, + transfers_by_comment_id: Nodes, +} + +#[derive(Deserialize)] +struct Author { + id: String, + identity: Option, +} + +#[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, +} + +#[derive(Debug)] +pub enum Reading { + /// No indexer answered. + Silent, + /// The comments found (possibly none). + Found(Vec), +} + +fn amount(v: &serde_json::Value) -> Option { + match v { + serde_json::Value::String(s) => s.parse().ok(), + serde_json::Value::Number(n) => n.as_u64(), + _ => None, + } +} + +fn found(node: Node) -> Option { + 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::>>()?; + 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> { + 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 = 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 = 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"); + } +} diff --git a/crates/ld-server/src/lib.rs b/crates/ld-server/src/lib.rs index f3fb3c8..1a1a614 100644 --- a/crates/ld-server/src/lib.rs +++ b/crates/ld-server/src/lib.rs @@ -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 { .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"); diff --git a/crates/ld-server/src/overview.rs b/crates/ld-server/src/overview.rs index 8ef0433..bd8cc66 100644 --- a/crates/ld-server/src/overview.rs +++ b/crates/ld-server/src/overview.rs @@ -109,7 +109,7 @@ pub async fn decisions( ) -> AppResult> { 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 '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) diff --git a/crates/ld-server/src/proposals.rs b/crates/ld-server/src/proposals.rs index 1081296..220c60f 100644 --- a/crates/ld-server/src/proposals.rs +++ b/crates/ld-server/src/proposals.rs @@ -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, } +/// 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, User(user): User, @@ -673,37 +675,23 @@ pub async fn vote( Json(b): Json, ) -> AppResult> { 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 }))) } diff --git a/crates/ld-server/tests/api.rs b/crates/ld-server/tests/api.rs index 426a17f..3563864 100644 --- a/crates/ld-server/tests/api.rs +++ b/crates/ld-server/tests/api.rs @@ -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; diff --git a/crates/ld-server/tests/ballots.rs b/crates/ld-server/tests/ballots.rs new file mode 100644 index 0000000..b05e7d1 --- /dev/null +++ b/crates/ld-server/tests/ballots.rs @@ -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, + silent: bool, + asked: usize, +} + +#[derive(Clone)] +struct Indexer { + chain: Arc>, + 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>>, Json(body): Json) -> 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 = 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 = 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, +} + +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) -> (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 { + 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 = 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::().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) = + 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 = + 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 = (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); +} diff --git a/crates/ld-server/tests/fixtures/indexer-merci.json b/crates/ld-server/tests/fixtures/indexer-merci.json new file mode 100644 index 0000000..f9ecc23 --- /dev/null +++ b/crates/ld-server/tests/fixtures/indexer-merci.json @@ -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" + } + ] + } + } + ] + } + } +} diff --git a/crates/ld-server/tests/inertia.rs b/crates/ld-server/tests/inertia.rs index 2d627ff..aa144cd 100644 --- a/crates/ld-server/tests/inertia.rs +++ b/crates/ld-server/tests/inertia.rs @@ -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"); diff --git a/crates/ld-server/tests/overview.rs b/crates/ld-server/tests/overview.rs index ec23069..e4b6d64 100644 --- a/crates/ld-server/tests/overview.rs +++ b/crates/ld-server/tests/overview.rs @@ -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] diff --git a/crates/trustin-access/src/lib.rs b/crates/trustin-access/src/lib.rs index c5c3704..2361538 100644 --- a/crates/trustin-access/src/lib.rs +++ b/crates/trustin-access/src/lib.rs @@ -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] diff --git a/docker/app-compose.yml b/docker/app-compose.yml index 2a029c7..fa17c0d 100644 --- a/docker/app-compose.yml +++ b/docker/app-compose.yml @@ -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 diff --git a/docs/adr/0006-acces-connect-trustinwallet.md b/docs/adr/0006-acces-connect-trustinwallet.md index 23a3996..8998c93 100644 --- a/docs/adr/0006-acces-connect-trustinwallet.md +++ b/docs/adr/0006-acces-connect-trustinwallet.md @@ -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. diff --git a/docs/adr/0008-vote-par-transaction.md b/docs/adr/0008-vote-par-transaction.md new file mode 100644 index 0000000..8f0658b --- /dev/null +++ b/docs/adr/0008-vote-par-transaction.md @@ -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 +author +line +… +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://?amount=0.1&comment=ld1:` (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:` (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). diff --git a/docs/adr/README.md b/docs/adr/README.md index 061724a..b52ba84 100644 --- a/docs/adr/README.md +++ b/docs/adr/README.md @@ -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 | diff --git a/docs/chantiers/acte-certification/CHANTIER.md b/docs/chantiers/acte-certification/CHANTIER.md index 3685366..8fe23b2 100644 --- a/docs/chantiers/acte-certification/CHANTIER.md +++ b/docs/chantiers/acte-certification/CHANTIER.md @@ -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 diff --git a/docs/chantiers/acte-certification/CONTROLES.md b/docs/chantiers/acte-certification/CONTROLES.md index 39251fc..e4da467 100644 --- a/docs/chantiers/acte-certification/CONTROLES.md +++ b/docs/chantiers/acte-certification/CONTROLES.md @@ -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://?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 | diff --git a/web/e2e/chain-mock.mjs b/web/e2e/chain-mock.mjs new file mode 100644 index 0000000..7ea7e5a --- /dev/null +++ b/web/e2e/chain-mock.mjs @@ -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}`); +}); diff --git a/web/e2e/entree.spec.ts b/web/e2e/entree.spec.ts index ce6ac02..73a8854 100644 --- a/web/e2e/entree.spec.ts +++ b/web/e2e/entree.spec.ts @@ -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'); diff --git a/web/e2e/helpers.ts b/web/e2e/helpers.ts index 3b041b2..b9c95a8 100644 --- a/web/e2e/helpers.ts +++ b/web/e2e/helpers.ts @@ -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 & { __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) => { + 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(), + }; +} diff --git a/web/e2e/mobile.spec.ts b/web/e2e/mobile.spec.ts index d7ebfee..15ffc4f 100644 --- a/web/e2e/mobile.spec.ts +++ b/web/e2e/mobile.spec.ts @@ -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 { + 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(); }); diff --git a/web/e2e/reader.spec.ts b/web/e2e/reader.spec.ts index c4ee3dc..b105d87 100644 --- a/web/e2e/reader.spec.ts +++ b/web/e2e/reader.spec.ts @@ -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(); }); diff --git a/web/e2e/serve.sh b/web/e2e/serve.sh index 3cf3501..15e69e2 100755 --- a/web/e2e/serve.sh +++ b/web/e2e/serve.sh @@ -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 diff --git a/web/e2e/vote.spec.ts b/web/e2e/vote.spec.ts new file mode 100644 index 0000000..decdaf3 --- /dev/null +++ b/web/e2e/vote.spec.ts @@ -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(); +}); diff --git a/web/package-lock.json b/web/package-lock.json index 9f32c28..f95f69b 100644 --- a/web/package-lock.json +++ b/web/package-lock.json @@ -15,13 +15,57 @@ "@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" } }, + "node_modules/@babel/code-frame": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.29.7.tgz", + "integrity": "sha512-Aup7aUOfpbAUg2ROOJN6Iw5f9DMBlzu0mIkm/malLQFN/YQgO48wCj0Kxa3sEHJvPVFg7siR+qRInwXd2qhQKw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-validator-identifier": "^7.29.7", + "js-tokens": "^4.0.0", + "picocolors": "^1.1.1" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/code-frame/node_modules/js-tokens": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-4.0.0.tgz", + "integrity": "sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/@babel/helper-validator-identifier": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-validator-identifier/-/helper-validator-identifier-7.29.7.tgz", + "integrity": "sha512-qehxGkRj55h/ff8EMaJ+cYhyaKlHIxqYDn682wQD7RNp9UujOQsHog2uS0r2vzr4pW+sXf90NeeayjcNaX3fFg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@commander-js/extra-typings": { + "version": "15.0.0", + "resolved": "https://registry.npmjs.org/@commander-js/extra-typings/-/extra-typings-15.0.0.tgz", + "integrity": "sha512-yeJlba62xqmkgELUsn7356MEnzLLu/fw2x4lofFqGnXh6YysRdEs2BaLeLtg1+KU0AXvMeqQvTTp+3hBEBK+EA==", + "dev": true, + "license": "MIT", + "peerDependencies": { + "commander": "~15.0.0" + } + }, "node_modules/@esbuild/aix-ppc64": { "version": "0.28.2", "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.28.2.tgz", @@ -551,6 +595,19 @@ "node": "^22.20 || ^24.12 || >=25" } }, + "node_modules/@noble/hashes": { + "version": "2.4.0", + "resolved": "https://registry.npmjs.org/@noble/hashes/-/hashes-2.4.0.tgz", + "integrity": "sha512-X5XaVWZIBCT7HHZGm5I7ZQXDwLG+bGXuSrMQAW+7Zvl87h1kmc1ZB1VSRJcpUfoUrGQp4Fkoxm5kZ+Ms+aW+eA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 20.19.0" + }, + "funding": { + "url": "https://paulmillr.com/funding/" + } + }, "node_modules/@playwright/test": { "version": "1.63.0", "resolved": "https://registry.npmjs.org/@playwright/test/-/test-1.63.0.tgz", @@ -574,6 +631,367 @@ "dev": true, "license": "MIT" }, + "node_modules/@polkadot-api/cli": { + "version": "0.22.5", + "resolved": "https://registry.npmjs.org/@polkadot-api/cli/-/cli-0.22.5.tgz", + "integrity": "sha512-kMzV11SNhY+F5c+seMS8ieEnq3r8azbS81AgJZL8a8gKU7Q2W5E/sG/hetTd7NZCeLnYvDWpbat1GCZzxita0A==", + "dev": true, + "license": "MIT", + "dependencies": { + "@commander-js/extra-typings": "^15.0.0", + "@polkadot-api/codegen": "0.23.2", + "@polkadot-api/ink-contracts": "0.7.2", + "@polkadot-api/json-rpc-provider": "0.2.0", + "@polkadot-api/known-chains": "0.12.5", + "@polkadot-api/metadata-compatibility": "0.7.2", + "@polkadot-api/observable-client": "0.19.4", + "@polkadot-api/sm-provider": "0.3.10", + "@polkadot-api/smoldot": "0.4.9", + "@polkadot-api/substrate-bindings": "0.21.2", + "@polkadot-api/substrate-client": "0.7.0", + "@polkadot-api/utils": "0.4.0", + "@polkadot-api/wasm-executor": "^0.2.3", + "@polkadot-api/ws-middleware": "0.4.2", + "@polkadot-api/ws-provider": "0.9.2", + "@types/node": "^26.6.5", + "commander": "^15.0.0", + "execa": "^10.2.0", + "fs.promises.exists": "^1.1.4", + "ora": "^9.4.1", + "read-pkg": "^10.1.0", + "rollup": "^4.64.12", + "rollup-plugin-esbuild": "^6.2.1", + "rxjs": "^7.8.2", + "tsc-prog": "^2.3.0", + "typescript": "^6.0.3", + "write-package": "^7.2.0" + }, + "bin": { + "papi": "dist/main/src/main.js", + "polkadot-api": "dist/main/src/main.js" + } + }, + "node_modules/@polkadot-api/cli/node_modules/@types/node": { + "version": "26.6.6", + "resolved": "https://registry.npmjs.org/@types/node/-/node-26.6.6.tgz", + "integrity": "sha512-MzQj+8Kx3XI40OmUAE6Qoh58ZMNstNNMwn2nfd3qWsfe7nDNSqJNzes4njhz6AHwrSHOffSLwXU6yb474Q4aog==", + "dev": true, + "license": "MIT", + "dependencies": { + "undici-types": "~8.9.0" + } + }, + "node_modules/@polkadot-api/cli/node_modules/typescript": { + "version": "6.0.3", + "resolved": "https://registry.npmjs.org/typescript/-/typescript-6.0.3.tgz", + "integrity": "sha512-y2TvuxSZPDyQakkFRPZHKFm+KKVqIisdg9/CZwm9ftvKXLP8NRWj38/ODjNbr43SsoXqNuAisEf1GdCxqWcdBw==", + "dev": true, + "license": "Apache-2.0", + "bin": { + "tsc": "bin/tsc", + "tsserver": "bin/tsserver" + }, + "engines": { + "node": ">=14.17" + } + }, + "node_modules/@polkadot-api/cli/node_modules/undici-types": { + "version": "8.9.0", + "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-8.9.0.tgz", + "integrity": "sha512-KTDyRTYX8sWmKXAikPHHSyc63CRPETMctyjKFupcC6OBLXT3xsN0e9aF7m+mIXutFWpUXuedtowG7iLOzp0kQg==", + "dev": true, + "license": "MIT" + }, + "node_modules/@polkadot-api/codegen": { + "version": "0.23.2", + "resolved": "https://registry.npmjs.org/@polkadot-api/codegen/-/codegen-0.23.2.tgz", + "integrity": "sha512-vV7UybrNMCtB79phMBFVWoVaYO8ApVE/5fE5uw7TBUMFTcDZZ3VwVM2qVnNCK/X4jH/FAnMSGoOrdjRVQyeSFA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@polkadot-api/ink-contracts": "0.7.2", + "@polkadot-api/metadata-builders": "0.15.2", + "@polkadot-api/metadata-compatibility": "0.7.2", + "@polkadot-api/substrate-bindings": "0.21.2", + "@polkadot-api/utils": "0.4.0" + } + }, + "node_modules/@polkadot-api/ink-contracts": { + "version": "0.7.2", + "resolved": "https://registry.npmjs.org/@polkadot-api/ink-contracts/-/ink-contracts-0.7.2.tgz", + "integrity": "sha512-ipKLbaboWImnjO0TuqLJNDdzoC0tLUTvJ+XNKW0wWm1OM2cXX8X0nXfAfTGy14PdUkyKjLncoPNoqgbEhmAh/Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "@polkadot-api/metadata-builders": "0.15.2", + "@polkadot-api/substrate-bindings": "0.21.2", + "@polkadot-api/utils": "0.4.0" + } + }, + "node_modules/@polkadot-api/json-rpc-provider": { + "version": "0.2.0", + "resolved": "https://registry.npmjs.org/@polkadot-api/json-rpc-provider/-/json-rpc-provider-0.2.0.tgz", + "integrity": "sha512-lhkuBS/x06i3djQIN7p8jVkMnYuGsUAEMS6RhdWeEpz7X/8/APER4Wdih7MEBovCuwVSCTjOxl8f+alH7AZHZg==", + "dev": true, + "license": "MIT" + }, + "node_modules/@polkadot-api/json-rpc-provider-proxy": { + "version": "0.4.1", + "resolved": "https://registry.npmjs.org/@polkadot-api/json-rpc-provider-proxy/-/json-rpc-provider-proxy-0.4.1.tgz", + "integrity": "sha512-F1Hw01C60jn98KQ0vBbgcwAvEcMcKLsJ5kFzq600sgjc6Rnbn1VF/XjTdrjcIqvfpR9sXyGIrpImrov2Usbrsw==", + "dev": true, + "license": "MIT" + }, + "node_modules/@polkadot-api/known-chains": { + "version": "0.12.5", + "resolved": "https://registry.npmjs.org/@polkadot-api/known-chains/-/known-chains-0.12.5.tgz", + "integrity": "sha512-odoCGRMKTMEq5wS851aw4qwEExvEX4j57sUI3R09+GW4RACnDqgbYxu8GKdqkXtrnHWAUr8lKMaN6RXaHfkTdQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/@polkadot-api/logs-provider": { + "version": "0.2.0", + "resolved": "https://registry.npmjs.org/@polkadot-api/logs-provider/-/logs-provider-0.2.0.tgz", + "integrity": "sha512-BH9YdxZu+ZBPPAUwGrvqHPn1hQStL2Im3MmTwYkwXOWW2HlGHULcF65QKvyK+T6/mj2vDvl3MgivnGkUw4pVxg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@polkadot-api/json-rpc-provider": "0.2.0" + } + }, + "node_modules/@polkadot-api/merkleize-metadata": { + "version": "1.3.2", + "resolved": "https://registry.npmjs.org/@polkadot-api/merkleize-metadata/-/merkleize-metadata-1.3.2.tgz", + "integrity": "sha512-M/ja1A+Hec16oGSiP8qvDuELe0JyxxEBwrfYjg/rE66yM/FiQ+29RrrmHQ8N20AtTlKn4RTaE3gbqQ02K6n1+w==", + "dev": true, + "license": "MIT", + "dependencies": { + "@polkadot-api/metadata-builders": "0.15.2", + "@polkadot-api/substrate-bindings": "0.21.2", + "@polkadot-api/utils": "0.4.0" + } + }, + "node_modules/@polkadot-api/metadata-builders": { + "version": "0.15.2", + "resolved": "https://registry.npmjs.org/@polkadot-api/metadata-builders/-/metadata-builders-0.15.2.tgz", + "integrity": "sha512-V8drZbzvu6Aa1EKQs4x/jOLFO05uATuEZagSqQ76ZF4skb0T8qhFmFrXnYpIiwkPndxo0mbY73cfz7rRdpshvw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@polkadot-api/substrate-bindings": "0.21.2", + "@polkadot-api/utils": "0.4.0" + } + }, + "node_modules/@polkadot-api/metadata-compatibility": { + "version": "0.7.2", + "resolved": "https://registry.npmjs.org/@polkadot-api/metadata-compatibility/-/metadata-compatibility-0.7.2.tgz", + "integrity": "sha512-sDIbIZHNbf6bm1k56RZf+vUakwni7p8OpO8UUr+7k2KVeQ6K+z1lWmY+Y23IwcgvUI8Lh+2SxH7vPtAsggevSw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@polkadot-api/metadata-builders": "0.15.2", + "@polkadot-api/substrate-bindings": "0.21.2" + } + }, + "node_modules/@polkadot-api/observable-client": { + "version": "0.19.4", + "resolved": "https://registry.npmjs.org/@polkadot-api/observable-client/-/observable-client-0.19.4.tgz", + "integrity": "sha512-xGzTQwdFfy7SYjS4n6op99rVJwCU0ghAHAEYLTfs3K1bO9dNZcgfduN/6imvdaZCOhZ2SPLBKjJvcEBUIzHW1Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "@polkadot-api/metadata-builders": "0.15.2", + "@polkadot-api/substrate-bindings": "0.21.2", + "@polkadot-api/substrate-client": "0.7.0", + "@polkadot-api/utils": "0.4.0" + }, + "peerDependencies": { + "rxjs": ">=7.8.0" + } + }, + "node_modules/@polkadot-api/pjs-signer": { + "version": "0.9.0", + "resolved": "https://registry.npmjs.org/@polkadot-api/pjs-signer/-/pjs-signer-0.9.0.tgz", + "integrity": "sha512-FMVMh5Gqmx0SFrsRJZgYwk44wfNz8N9vqz8zquj6eQYk+XB8KNEds15YCxsCFuyYVzKQ9CRnLSpOUkh24jY6BA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@polkadot-api/metadata-builders": "0.15.2", + "@polkadot-api/raw-tx-creator": "0.5.0", + "@polkadot-api/signers-common": "0.4.0", + "@polkadot-api/substrate-bindings": "0.21.2", + "@polkadot-api/tx-creator": "0.2.0", + "@polkadot-api/utils": "0.4.0" + } + }, + "node_modules/@polkadot-api/raw-client": { + "version": "0.3.0", + "resolved": "https://registry.npmjs.org/@polkadot-api/raw-client/-/raw-client-0.3.0.tgz", + "integrity": "sha512-u/wM9W7ugIXxBSOEV8+zvQI43b5vgSI0pvE0Rg8PV0G65BTLK0SMc2o2SQL0HtS5+bi5sw/gg4reBJ/0a4U0cw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@polkadot-api/json-rpc-provider": "0.2.0" + } + }, + "node_modules/@polkadot-api/raw-tx-creator": { + "version": "0.5.0", + "resolved": "https://registry.npmjs.org/@polkadot-api/raw-tx-creator/-/raw-tx-creator-0.5.0.tgz", + "integrity": "sha512-NVI7rFc+4x6fjKumR0W6CWA8EbSF8YnPI0zns8wL4qmC/FS1VfwCW4zP3EcGZGcxP8lOc7V0pS7Ky/OpbAIAFw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@noble/hashes": "^2.4.0", + "@polkadot-api/merkleize-metadata": "1.3.2", + "@polkadot-api/metadata-builders": "0.15.2", + "@polkadot-api/signers-common": "0.4.0", + "@polkadot-api/substrate-bindings": "0.21.2", + "@polkadot-api/tx-creator": "0.2.0", + "@polkadot-api/utils": "0.4.0" + }, + "peerDependencies": { + "rxjs": ">=7.8.0" + } + }, + "node_modules/@polkadot-api/signers-common": { + "version": "0.4.0", + "resolved": "https://registry.npmjs.org/@polkadot-api/signers-common/-/signers-common-0.4.0.tgz", + "integrity": "sha512-jMSZxMcfzPqgU+wVT75H7a5cHBS59ppoGghG3L7Ef9FguFa4p6LvH4pK2xjEQ2i21HIZ7KKKojWSSklnQzUUrg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@polkadot-api/metadata-builders": "0.15.2", + "@polkadot-api/substrate-bindings": "0.21.2", + "@polkadot-api/tx-creator": "0.2.0", + "@polkadot-api/utils": "0.4.0" + }, + "peerDependencies": { + "rxjs": ">=7.8.0" + } + }, + "node_modules/@polkadot-api/sm-provider": { + "version": "0.3.10", + "resolved": "https://registry.npmjs.org/@polkadot-api/sm-provider/-/sm-provider-0.3.10.tgz", + "integrity": "sha512-TsuUf81ej0zIeSsSo8c0MEJO5Z3iO7ZMBlv5A6JPcmCx9bZhS/Vjj+DWQW7V+M05KYWdlbS//2tpOg69xgb7sA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@polkadot-api/json-rpc-provider": "0.2.0", + "@polkadot-api/json-rpc-provider-proxy": "0.4.1" + }, + "peerDependencies": { + "@polkadot-api/smoldot": ">=0.3" + } + }, + "node_modules/@polkadot-api/smoldot": { + "version": "0.4.9", + "resolved": "https://registry.npmjs.org/@polkadot-api/smoldot/-/smoldot-0.4.9.tgz", + "integrity": "sha512-UbebkD0DOH3UIUAQAhto8UhyVaG3zmiquts4n7BDhZ2x8JSGpskHZv1VJT7GsCJcYBfHy2L5btZpgYrgpKU1CA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/node": "^26.6.5", + "smoldot": "~3.7.0" + } + }, + "node_modules/@polkadot-api/smoldot/node_modules/@types/node": { + "version": "26.6.6", + "resolved": "https://registry.npmjs.org/@types/node/-/node-26.6.6.tgz", + "integrity": "sha512-MzQj+8Kx3XI40OmUAE6Qoh58ZMNstNNMwn2nfd3qWsfe7nDNSqJNzes4njhz6AHwrSHOffSLwXU6yb474Q4aog==", + "dev": true, + "license": "MIT", + "dependencies": { + "undici-types": "~8.9.0" + } + }, + "node_modules/@polkadot-api/smoldot/node_modules/undici-types": { + "version": "8.9.0", + "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-8.9.0.tgz", + "integrity": "sha512-KTDyRTYX8sWmKXAikPHHSyc63CRPETMctyjKFupcC6OBLXT3xsN0e9aF7m+mIXutFWpUXuedtowG7iLOzp0kQg==", + "dev": true, + "license": "MIT" + }, + "node_modules/@polkadot-api/substrate-bindings": { + "version": "0.21.2", + "resolved": "https://registry.npmjs.org/@polkadot-api/substrate-bindings/-/substrate-bindings-0.21.2.tgz", + "integrity": "sha512-YjQnmS1dfoQky3Qoddm59CCK+5jxl57uvXtGt0qwbgJ7y0zHHznBCDPv8lU4iUzQKS4dn7aSwIi5j7CE3DGAbQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@noble/hashes": "^2.4.0", + "@polkadot-api/utils": "0.4.0", + "@scure/base": "^2.4.0", + "scale-ts": "^1.6.1" + } + }, + "node_modules/@polkadot-api/substrate-client": { + "version": "0.7.0", + "resolved": "https://registry.npmjs.org/@polkadot-api/substrate-client/-/substrate-client-0.7.0.tgz", + "integrity": "sha512-TWCc4MAMa5SLVQXmomLHknbj+bztQ/Yclgwm8ENBhz8hR7c9rw9FBAkCa02jMBMCAygPhp3ayGRq+UFcF8KIxQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@polkadot-api/json-rpc-provider": "0.2.0", + "@polkadot-api/raw-client": "0.3.0", + "@polkadot-api/utils": "0.4.0" + } + }, + "node_modules/@polkadot-api/tx-creator": { + "version": "0.2.0", + "resolved": "https://registry.npmjs.org/@polkadot-api/tx-creator/-/tx-creator-0.2.0.tgz", + "integrity": "sha512-xac4Lx/8bB7+glcU/HG8uzPYdRFcs0MkSni07xrTtOizWt6j07Q9XEPxtqmT7aL5Ne93uCl0bhvk+VhezHbf0w==", + "dev": true, + "license": "MIT", + "peerDependencies": { + "rxjs": "^7.8.2" + } + }, + "node_modules/@polkadot-api/utils": { + "version": "0.4.0", + "resolved": "https://registry.npmjs.org/@polkadot-api/utils/-/utils-0.4.0.tgz", + "integrity": "sha512-9b/hwRM0UloLWV7SfpNaSD/4k8UQAHoaACAk7Xe+1MlfAm2JtnmPiB1GfGrfTyBlsrJVUIBCZpEmbmxVMaIqBA==", + "dev": true, + "license": "MIT" + }, + "node_modules/@polkadot-api/wasm-executor": { + "version": "0.2.3", + "resolved": "https://registry.npmjs.org/@polkadot-api/wasm-executor/-/wasm-executor-0.2.3.tgz", + "integrity": "sha512-B2h1o+Qlo9idpASaHvMSoViB2I5ko5OAfwfhYF8LQDkTADK0B+SeStzNj1Qn+FG34wqTuv7HzBCdjaUgzYINJQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/@polkadot-api/ws-middleware": { + "version": "0.4.2", + "resolved": "https://registry.npmjs.org/@polkadot-api/ws-middleware/-/ws-middleware-0.4.2.tgz", + "integrity": "sha512-UostyJupfCk5jh+MUOjBBZ6qRYwQ2UhPWGnv25sVc0thRzBO9tV93sMg+jsfGLyNOSBmW8F9UlzXYut1/vEfIA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@polkadot-api/json-rpc-provider": "0.2.0", + "@polkadot-api/json-rpc-provider-proxy": "0.4.1", + "@polkadot-api/raw-client": "0.3.0", + "@polkadot-api/substrate-bindings": "0.21.2", + "@polkadot-api/utils": "0.4.0" + }, + "peerDependencies": { + "rxjs": ">=7.8.0" + } + }, + "node_modules/@polkadot-api/ws-provider": { + "version": "0.9.2", + "resolved": "https://registry.npmjs.org/@polkadot-api/ws-provider/-/ws-provider-0.9.2.tgz", + "integrity": "sha512-FWISrSF4op3WayDUtxjLGDtq/KfhnrbseJRp7UxuMNccwCHaGpwkFvO5meVLN71BaHyFv9gCFA3rV6tPDxcVdg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@polkadot-api/json-rpc-provider": "0.2.0", + "@polkadot-api/json-rpc-provider-proxy": "0.4.1", + "@polkadot-api/utils": "0.4.0" + }, + "peerDependencies": { + "rxjs": ">=7.8.0" + } + }, "node_modules/@rollup/rollup-android-arm-eabi": { "version": "4.64.12", "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.64.12.tgz", @@ -924,6 +1342,46 @@ "win32" ] }, + "node_modules/@rx-state/core": { + "version": "0.1.4", + "resolved": "https://registry.npmjs.org/@rx-state/core/-/core-0.1.4.tgz", + "integrity": "sha512-Z+3hjU2xh1HisLxt+W5hlYX/eGSDaXXP+ns82gq/PLZpkXLu0uwcNUh9RLY3Clq4zT+hSsA3vcpIGt6+UAb8rQ==", + "dev": true, + "license": "MIT", + "peerDependencies": { + "rxjs": ">=7" + } + }, + "node_modules/@scure/base": { + "version": "2.4.0", + "resolved": "https://registry.npmjs.org/@scure/base/-/base-2.4.0.tgz", + "integrity": "sha512-thZ1TuJwFwBblOhgsjDKvvGirBxNp+wSvY/DR6tJBJOTDhdAAcHJ8Vbr2eFnqaxeca4+t0i9KBf+uHYGWwZORg==", + "dev": true, + "license": "MIT", + "funding": { + "url": "https://paulmillr.com/funding/" + } + }, + "node_modules/@sec-ant/readable-stream": { + "version": "0.4.1", + "resolved": "https://registry.npmjs.org/@sec-ant/readable-stream/-/readable-stream-0.4.1.tgz", + "integrity": "sha512-831qok9r2t8AlxLko40y2ebgSDhenenCatLVeW/uBtnHPyhHOvG0C7TvfgecV+wHzIm5KUICgzmVpWS+IMEAeg==", + "dev": true, + "license": "MIT" + }, + "node_modules/@sindresorhus/merge-streams": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/@sindresorhus/merge-streams/-/merge-streams-4.0.0.tgz", + "integrity": "sha512-tlqY9xq5ukxTUZBmoOp+m61cqwQD5pHJtFY3Mn8CA8ps6yghLH/Hw8UPdqg4OLmFW3IFlcXnQNmo/dh8HzXYIQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/@standard-schema/spec": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/@standard-schema/spec/-/spec-1.1.0.tgz", @@ -1084,6 +1542,13 @@ "undici-types": "~6.21.0" } }, + "node_modules/@types/normalize-package-data": { + "version": "2.4.4", + "resolved": "https://registry.npmjs.org/@types/normalize-package-data/-/normalize-package-data-2.4.4.tgz", + "integrity": "sha512-37i+OaWTh9qeK4LSHPsyRC7NahnGotNuZvjLSgcPzblpHB3rrCJxAOgI5gCdKm7coonsaX1Of0ILiTcnZjbfxA==", + "dev": true, + "license": "MIT" + }, "node_modules/@vitest/expect": { "version": "3.2.7", "resolved": "https://registry.npmjs.org/@vitest/expect/-/expect-3.2.7.tgz", @@ -1212,6 +1677,19 @@ "node": ">=0.4.0" } }, + "node_modules/ansi-regex": { + "version": "6.5.0", + "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-6.5.0.tgz", + "integrity": "sha512-zhvbbbdt1kbw7EJ9OQoxmQcuaNR9P5kTj7e/YdmOSU10aHWbycofJxA6jKfoEy3vlLdctOF9ZHaxLtmSm998wQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/chalk/ansi-regex?sponsor=1" + } + }, "node_modules/aria-query": { "version": "5.3.1", "resolved": "https://registry.npmjs.org/aria-query/-/aria-query-5.3.1.tgz", @@ -1269,6 +1747,19 @@ "node": ">=18" } }, + "node_modules/chalk": { + "version": "5.6.2", + "resolved": "https://registry.npmjs.org/chalk/-/chalk-5.6.2.tgz", + "integrity": "sha512-7NzBL0rN6fMUW+f7A6Io4h40qQlG+xGmtMxfbnH/K7TAtt8JQWVQK+6g0UXKMeVJoyV5EkkNsErQ8pVD3bLHbA==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^12.17.0 || ^14.13 || >=16.0.0" + }, + "funding": { + "url": "https://github.com/chalk/chalk?sponsor=1" + } + }, "node_modules/check-error": { "version": "2.1.3", "resolved": "https://registry.npmjs.org/check-error/-/check-error-2.1.3.tgz", @@ -1295,6 +1786,35 @@ "url": "https://paulmillr.com/funding/" } }, + "node_modules/cli-cursor": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/cli-cursor/-/cli-cursor-5.0.0.tgz", + "integrity": "sha512-aCj4O5wKyszjMmDT4tZj93kxyydN/K5zPWSCe6/0AV/AA1pqe5ZBIw0a2ZfPQV7lL5/yb5HsUreJ6UFAF1tEQw==", + "dev": true, + "license": "MIT", + "dependencies": { + "restore-cursor": "^5.0.0" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/cli-spinners": { + "version": "3.4.0", + "resolved": "https://registry.npmjs.org/cli-spinners/-/cli-spinners-3.4.0.tgz", + "integrity": "sha512-bXfOC4QcT1tKXGorxL3wbJm6XJPDqEnij2gQ2m7ESQuE+/z9YFIWnl/5RpTiKWbMq3EVKR4fRLJGn6DVfu0mpw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18.20" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/clsx": { "version": "2.1.1", "resolved": "https://registry.npmjs.org/clsx/-/clsx-2.1.1.tgz", @@ -1305,6 +1825,16 @@ "node": ">=6" } }, + "node_modules/commander": { + "version": "15.0.0", + "resolved": "https://registry.npmjs.org/commander/-/commander-15.0.0.tgz", + "integrity": "sha512-z67u4ZhzCL/Tydu1lJARtEZYWbWaN7oYLHbsuzocr6y4N6WZAagG3RQ4FW61V1/0+jImpj293XfrcYnd1qxtPg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=22.12.0" + } + }, "node_modules/cookie": { "version": "0.6.0", "resolved": "https://registry.npmjs.org/cookie/-/cookie-0.6.0.tgz", @@ -1353,6 +1883,39 @@ "node": ">=0.10.0" } }, + "node_modules/deepmerge-ts": { + "version": "7.1.6", + "resolved": "https://registry.npmjs.org/deepmerge-ts/-/deepmerge-ts-7.1.6.tgz", + "integrity": "sha512-gQhL1ksGBLQbHeAo47YU6cs2ahd3Pv+8PFYoWogNZbQnNwQyOh9Ad5kbeHFiWwbKdeWQJ5Z7Y7mwb9ew2hXBLw==", + "dev": true, + "funding": [ + { + "type": "ko-fi", + "url": "https://ko-fi.com/rebeccastevens" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/deepmerge-ts" + } + ], + "license": "BSD-3-Clause", + "engines": { + "node": ">=16.0.0" + } + }, + "node_modules/detect-indent": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/detect-indent/-/detect-indent-7.0.2.tgz", + "integrity": "sha512-y+8xyqdGLL+6sh0tVeHcfP/QDd8gUgbasolJJpY7NgeQGSZ739bDtSiaiDgtoicy+mtYB81dKLxO9xRhCyIB3A==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12.20" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/devalue": { "version": "5.9.4", "resolved": "https://registry.npmjs.org/devalue/-/devalue-5.9.4.tgz", @@ -1444,6 +2007,33 @@ "@types/estree": "^1.0.0" } }, + "node_modules/execa": { + "version": "10.2.1", + "resolved": "https://registry.npmjs.org/execa/-/execa-10.2.1.tgz", + "integrity": "sha512-/Bofl3ZBh2Q4/PG0W63qFzP5vDAypi8Zc90UWTBzaDNIYiX7apPOd0N6L8hG8CojUdq4V51adu8BObP+iVsSTw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@sindresorhus/merge-streams": "^4.0.0", + "figures": "^6.1.0", + "get-stream": "^9.0.1", + "human-signals": "^8.0.1", + "is-plain-obj": "^4.1.0", + "is-stream": "^4.0.1", + "npm-run-path": "^6.0.0", + "pretty-ms": "^9.3.1", + "signal-exit": "^4.1.0", + "strip-final-newline": "^4.0.0", + "which-command": "^0.1.0", + "yoctocolors": "^2.2.0" + }, + "engines": { + "node": ">=22" + }, + "funding": { + "url": "https://github.com/sindresorhus/execa?sponsor=1" + } + }, "node_modules/expect-type": { "version": "1.4.0", "resolved": "https://registry.npmjs.org/expect-type/-/expect-type-1.4.0.tgz", @@ -1472,6 +2062,32 @@ } } }, + "node_modules/figures": { + "version": "6.1.0", + "resolved": "https://registry.npmjs.org/figures/-/figures-6.1.0.tgz", + "integrity": "sha512-d+l3qxjSesT4V7v2fh+QnmFnUWv9lSpjarhShNTgBOfA0ttejbQUAlHLitbjkoRiDulW0OPoQPYIGhIC8ohejg==", + "dev": true, + "license": "MIT", + "dependencies": { + "is-unicode-supported": "^2.0.0" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/fs.promises.exists": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/fs.promises.exists/-/fs.promises.exists-1.1.4.tgz", + "integrity": "sha512-lJzUGWbZn8vhGWBedA+RYjB/BeJ+3458ljUfmplqhIeb6ewzTFWNPCR1HCiYCkXV9zxcHz9zXkJzMsEgDLzh3Q==", + "dev": true, + "license": "MIT", + "funding": { + "url": "https://github.com/privatenumber/fs.promises.exists?sponsor=1" + } + }, "node_modules/fsevents": { "version": "2.3.3", "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", @@ -1487,6 +2103,121 @@ "node": "^8.16.0 || ^10.6.0 || >=11.0.0" } }, + "node_modules/get-east-asian-width": { + "version": "1.7.0", + "resolved": "https://registry.npmjs.org/get-east-asian-width/-/get-east-asian-width-1.7.0.tgz", + "integrity": "sha512-XjH1AECxf0giL2V1aU8vKyRR2ppRUb5c0EvT7zuJTokQ74bNo52zOtghqdWIqrhUD79fo3x0WfKZdOqxF6LG1Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/get-stream": { + "version": "9.0.1", + "resolved": "https://registry.npmjs.org/get-stream/-/get-stream-9.0.1.tgz", + "integrity": "sha512-kVCxPF3vQM/N0B1PmoqVUqgHP+EeVjmZSQn+1oCRPxd2P21P2F19lIgbR3HBosbB1PUhOAoctJnfEn2GbN2eZA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@sec-ant/readable-stream": "^0.4.1", + "is-stream": "^4.0.1" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/get-tsconfig": { + "version": "4.14.3", + "resolved": "https://registry.npmjs.org/get-tsconfig/-/get-tsconfig-4.14.3.tgz", + "integrity": "sha512-++QEw4DIY7WGoukz+/+A/8dGYPT9l9yIadnmSgZ8Rjr3YVSVDipQSO9CdnJo9ePqFqUUqh+wk9uIaoiAwsiPkA==", + "dev": true, + "license": "MIT", + "dependencies": { + "resolve-pkg-maps": "^1.0.0" + }, + "funding": { + "url": "https://github.com/privatenumber/get-tsconfig?sponsor=1" + } + }, + "node_modules/hosted-git-info": { + "version": "9.0.3", + "resolved": "https://registry.npmjs.org/hosted-git-info/-/hosted-git-info-9.0.3.tgz", + "integrity": "sha512-Hc+ghLoSt6QaYZUv0WBiIvmMDZuZZ7oaDvdH8MbfOO4lOsxdXLEvuC6ePoGs9H1X9oCLyq6+NVN0MKqD+ydxyg==", + "dev": true, + "license": "ISC", + "dependencies": { + "lru-cache": "^11.1.0" + }, + "engines": { + "node": "^20.17.0 || >=22.9.0" + } + }, + "node_modules/human-signals": { + "version": "8.0.1", + "resolved": "https://registry.npmjs.org/human-signals/-/human-signals-8.0.1.tgz", + "integrity": "sha512-eKCa6bwnJhvxj14kZk5NCPc6Hb6BdsU9DZcOnmQKSnO1VKrfV0zCvtttPZUsBvjmNDn8rpcJfpwSYnHBjc95MQ==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=18.18.0" + } + }, + "node_modules/imurmurhash": { + "version": "0.1.4", + "resolved": "https://registry.npmjs.org/imurmurhash/-/imurmurhash-0.1.4.tgz", + "integrity": "sha512-JmXMZ6wuvDmLiHEml9ykzqO6lwFbof0GG4IkcGaENdCRDDmMVnny7s5HsIgHCbaq0w2MyPhDqkhTUgS2LU2PHA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.8.19" + } + }, + "node_modules/index-to-position": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/index-to-position/-/index-to-position-1.2.0.tgz", + "integrity": "sha512-Yg7+ztRkqslMAS2iFaU+Oa4KTSidr63OsFGlOrJoW981kIYO3CGCS3wA95P1mUi/IVSJkn0D479KTJpVpvFNuw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/is-interactive": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/is-interactive/-/is-interactive-2.0.0.tgz", + "integrity": "sha512-qP1vozQRI+BMOPcjFzrjXuQvdak2pHNUMZoeG2eRbiSqyvbEf/wQtEOTOX1guk6E3t36RkaqiSt8A/6YElNxLQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/is-plain-obj": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/is-plain-obj/-/is-plain-obj-4.1.0.tgz", + "integrity": "sha512-+Pgi+vMuUNkJyExiMBt5IlFoMyKnr5zhJ4Uspz58WOhBF5QoIZkFyNHIbBAtHwzVAgk5RtndVNsDRN61/mmDqg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/is-reference": { "version": "3.0.3", "resolved": "https://registry.npmjs.org/is-reference/-/is-reference-3.0.3.tgz", @@ -1497,6 +2228,32 @@ "@types/estree": "^1.0.6" } }, + "node_modules/is-stream": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/is-stream/-/is-stream-4.0.1.tgz", + "integrity": "sha512-Dnz92NInDqYckGEUJv689RbRiTSEHCQ7wOVeALbkOz999YpqT46yMRIGtSNl2iCL1waAZSx40+h59NV/EwzV/A==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/is-unicode-supported": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/is-unicode-supported/-/is-unicode-supported-2.1.0.tgz", + "integrity": "sha512-mE00Gnza5EEB3Ds0HfMyllZzbBrmLOX3vfWoj9A9PEnTfratQ/BcaJOuMhnkhjXvb2+FkY3VuHqtAGpTPmglFQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/js-tokens": { "version": "9.0.1", "resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-9.0.1.tgz", @@ -1521,6 +2278,23 @@ "dev": true, "license": "MIT" }, + "node_modules/log-symbols": { + "version": "7.0.1", + "resolved": "https://registry.npmjs.org/log-symbols/-/log-symbols-7.0.1.tgz", + "integrity": "sha512-ja1E3yCr9i/0hmBVaM0bfwDjnGy8I/s6PP4DFp+yP+a+mrHO4Rm7DtmnqROTUkHIkqffC84YY7AeqX6oFk0WFg==", + "dev": true, + "license": "MIT", + "dependencies": { + "is-unicode-supported": "^2.0.0", + "yoctocolors": "^2.1.1" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/loupe": { "version": "3.2.1", "resolved": "https://registry.npmjs.org/loupe/-/loupe-3.2.1.tgz", @@ -1528,6 +2302,16 @@ "dev": true, "license": "MIT" }, + "node_modules/lru-cache": { + "version": "11.5.3", + "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-11.5.3.tgz", + "integrity": "sha512-U4N8FgzmWxc8k1VH8Kr6lQg18U7Fjvby6wXHVRX/ZZ7IwWbRMgrRbP0Wrb5q5NVinryp4SQampHKdvtecItxUg==", + "dev": true, + "license": "BlueOak-1.0.0", + "engines": { + "node": "20 || >=22" + } + }, "node_modules/magic-string": { "version": "0.30.21", "resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.30.21.tgz", @@ -1538,6 +2322,19 @@ "@jridgewell/sourcemap-codec": "^1.5.5" } }, + "node_modules/mimic-function": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/mimic-function/-/mimic-function-5.0.1.tgz", + "integrity": "sha512-VP79XUPxV2CigYP3jWwAUFSku2aKqBH7uTAapFWCBqutsbmDo96KY5o8uh6U+/YSIn5OxJnXp73beVkpqMIGhA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/mri": { "version": "1.2.0", "resolved": "https://registry.npmjs.org/mri/-/mri-1.2.0.tgz", @@ -1584,6 +2381,38 @@ "node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1" } }, + "node_modules/normalize-package-data": { + "version": "8.0.0", + "resolved": "https://registry.npmjs.org/normalize-package-data/-/normalize-package-data-8.0.0.tgz", + "integrity": "sha512-RWk+PI433eESQ7ounYxIp67CYuVsS1uYSonX3kA6ps/3LWfjVQa/ptEg6Y3T6uAMq1mWpX9PQ+qx+QaHpsc7gQ==", + "dev": true, + "license": "BSD-2-Clause", + "dependencies": { + "hosted-git-info": "^9.0.0", + "semver": "^7.3.5", + "validate-npm-package-license": "^3.0.4" + }, + "engines": { + "node": "^20.17.0 || >=22.9.0" + } + }, + "node_modules/npm-run-path": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/npm-run-path/-/npm-run-path-6.0.0.tgz", + "integrity": "sha512-9qny7Z9DsQU8Ou39ERsPU4OZQlSTP47ShQzuKZ6PRXpYLtIFgl/DEBYEXKlvcEa+9tHVcK8CF81Y2V72qaZhWA==", + "dev": true, + "license": "MIT", + "dependencies": { + "path-key": "^4.0.0", + "unicorn-magic": "^0.3.0" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/obug": { "version": "2.2.1", "resolved": "https://registry.npmjs.org/obug/-/obug-2.2.1.tgz", @@ -1598,6 +2427,102 @@ "node": ">=12.20.0" } }, + "node_modules/onetime": { + "version": "7.0.0", + "resolved": "https://registry.npmjs.org/onetime/-/onetime-7.0.0.tgz", + "integrity": "sha512-VXJjc87FScF88uafS3JllDgvAm+c/Slfz06lorj2uAY34rlUu0Nt+v8wreiImcrgAjjIHp1rXpTDlLOGw29WwQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "mimic-function": "^5.0.0" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/ora": { + "version": "9.4.1", + "resolved": "https://registry.npmjs.org/ora/-/ora-9.4.1.tgz", + "integrity": "sha512-6VlU9MLXbjVQD04AZCMX28hVtA5bUoadvUqO76MUCVA0ilwJbMiHsITRPfyVm6p/BC0Av/BXMujx39WCe1LEqw==", + "dev": true, + "license": "MIT", + "dependencies": { + "chalk": "^5.6.2", + "cli-cursor": "^5.0.0", + "cli-spinners": "^3.2.0", + "is-interactive": "^2.0.0", + "is-unicode-supported": "^2.1.0", + "log-symbols": "^7.0.1", + "stdin-discarder": "^0.3.2", + "string-width": "^8.1.0" + }, + "engines": { + "node": ">=20" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/parse-json": { + "version": "8.3.0", + "resolved": "https://registry.npmjs.org/parse-json/-/parse-json-8.3.0.tgz", + "integrity": "sha512-ybiGyvspI+fAoRQbIPRddCcSTV9/LsJbf0e/S85VLowVGzRmokfneg2kwVW/KU5rOXrPSbF1qAKPMgNTqqROQQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/code-frame": "^7.26.2", + "index-to-position": "^1.1.0", + "type-fest": "^4.39.1" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/parse-json/node_modules/type-fest": { + "version": "4.41.0", + "resolved": "https://registry.npmjs.org/type-fest/-/type-fest-4.41.0.tgz", + "integrity": "sha512-TeTSQ6H5YHvpqVwBRcnLDCBnDOHWYu7IvGbHT6N8AOymcr9PJGjc1GTtiWZTYg0NCgYwvnYWEkVChQAr9bjfwA==", + "dev": true, + "license": "(MIT OR CC0-1.0)", + "engines": { + "node": ">=16" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/parse-ms": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/parse-ms/-/parse-ms-4.0.0.tgz", + "integrity": "sha512-TXfryirbmq34y8QBwgqCVLi+8oA3oWx2eAnSn62ITyEhEYaWRlVZ2DvMM9eZbMs/RfxPu/PK/aBLyGj4IrqMHw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/path-key": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/path-key/-/path-key-4.0.0.tgz", + "integrity": "sha512-haREypq7xkM7ErfgIyA0z+Bj4AGKlMSdlQE2jvJo6huWD1EdkKYV+G/T4nq0YEF2vgTT8kqMFKo1uHn950r4SQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/pathe": { "version": "2.0.3", "resolved": "https://registry.npmjs.org/pathe/-/pathe-2.0.3.tgz", @@ -1664,6 +2589,42 @@ "node": ">=20" } }, + "node_modules/polkadot-api": { + "version": "3.3.0", + "resolved": "https://registry.npmjs.org/polkadot-api/-/polkadot-api-3.3.0.tgz", + "integrity": "sha512-GZqdSZ/6BqyJro7LFo4JTlyATHJLdoCRWCLagurgLzowZRWYX/rlCWN7y5oFBFrMKRdfeGy5sAJzK3+5nkOdDA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@polkadot-api/cli": "0.22.5", + "@polkadot-api/ink-contracts": "0.7.2", + "@polkadot-api/json-rpc-provider": "0.2.0", + "@polkadot-api/known-chains": "0.12.5", + "@polkadot-api/logs-provider": "0.2.0", + "@polkadot-api/metadata-builders": "0.15.2", + "@polkadot-api/metadata-compatibility": "0.7.2", + "@polkadot-api/observable-client": "0.19.4", + "@polkadot-api/pjs-signer": "0.9.0", + "@polkadot-api/raw-tx-creator": "0.5.0", + "@polkadot-api/signers-common": "0.4.0", + "@polkadot-api/sm-provider": "0.3.10", + "@polkadot-api/smoldot": "0.4.9", + "@polkadot-api/substrate-bindings": "0.21.2", + "@polkadot-api/substrate-client": "0.7.0", + "@polkadot-api/tx-creator": "0.2.0", + "@polkadot-api/utils": "0.4.0", + "@polkadot-api/ws-middleware": "0.4.2", + "@polkadot-api/ws-provider": "0.9.2", + "@rx-state/core": "^0.1.4" + }, + "bin": { + "papi": "bin/cli.js", + "polkadot-api": "bin/cli.js" + }, + "peerDependencies": { + "rxjs": ">=7.8.0" + } + }, "node_modules/postcss": { "version": "8.5.31", "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.31.tgz", @@ -1693,6 +2654,55 @@ "node": "^10 || ^12 || >=14" } }, + "node_modules/pretty-ms": { + "version": "9.3.1", + "resolved": "https://registry.npmjs.org/pretty-ms/-/pretty-ms-9.3.1.tgz", + "integrity": "sha512-HzMy3Geq23nVALD/M2LliU+F+M+gVNsvkQWWqeBZ8HDiCgzo6YPJ/Omrmtq24EFrIsk0a3EkQGEd7bDOo+IhGA==", + "dev": true, + "license": "MIT", + "dependencies": { + "parse-ms": "^4.0.0" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/read-pkg": { + "version": "10.1.0", + "resolved": "https://registry.npmjs.org/read-pkg/-/read-pkg-10.1.0.tgz", + "integrity": "sha512-I8g2lArQiP78ll51UeMZojewtYgIRCKCWqZEgOO8c/uefTI+XDXvCSXu3+YNUaTNvZzobrL5+SqHjBrByRRTdg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/normalize-package-data": "^2.4.4", + "normalize-package-data": "^8.0.0", + "parse-json": "^8.3.0", + "type-fest": "^5.4.4", + "unicorn-magic": "^0.4.0" + }, + "engines": { + "node": ">=20" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/read-pkg/node_modules/unicorn-magic": { + "version": "0.4.1", + "resolved": "https://registry.npmjs.org/unicorn-magic/-/unicorn-magic-0.4.1.tgz", + "integrity": "sha512-lzlXPoVB0Uy/FvTaUgX39RbixYiSKoc3JSoSf01+0c2B6FR3qdYIPN7Qjo/AV7n6sqLCk0509cT3LRj1oSZ1+A==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=20" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/readdirp": { "version": "4.1.2", "resolved": "https://registry.npmjs.org/readdirp/-/readdirp-4.1.2.tgz", @@ -1707,6 +2717,33 @@ "url": "https://paulmillr.com/funding/" } }, + "node_modules/resolve-pkg-maps": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/resolve-pkg-maps/-/resolve-pkg-maps-1.0.0.tgz", + "integrity": "sha512-seS2Tj26TBVOC2NIc2rOe2y2ZO7efxITtLZcGSOnHHNOQ7CkiUBfw0Iw2ck6xkIhPwLhKNLS8BO+hEpngQlqzw==", + "dev": true, + "license": "MIT", + "funding": { + "url": "https://github.com/privatenumber/resolve-pkg-maps?sponsor=1" + } + }, + "node_modules/restore-cursor": { + "version": "5.1.0", + "resolved": "https://registry.npmjs.org/restore-cursor/-/restore-cursor-5.1.0.tgz", + "integrity": "sha512-oMA2dcrw6u0YfxJQXm342bFKX/E4sG9rbTzO9ptUcR/e8A33cHuvStiYOwH7fszkZlZ1z/ta9AAoPk2F4qIOHA==", + "dev": true, + "license": "MIT", + "dependencies": { + "onetime": "^7.0.0", + "signal-exit": "^4.1.0" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/rollup": { "version": "4.64.12", "resolved": "https://registry.npmjs.org/rollup/-/rollup-4.64.12.tgz", @@ -1753,6 +2790,36 @@ "fsevents": "~2.3.2" } }, + "node_modules/rollup-plugin-esbuild": { + "version": "6.2.1", + "resolved": "https://registry.npmjs.org/rollup-plugin-esbuild/-/rollup-plugin-esbuild-6.2.1.tgz", + "integrity": "sha512-jTNOMGoMRhs0JuueJrJqbW8tOwxumaWYq+V5i+PD+8ecSCVkuX27tGW7BXqDgoULQ55rO7IdNxPcnsWtshz3AA==", + "dev": true, + "license": "MIT", + "dependencies": { + "debug": "^4.4.0", + "es-module-lexer": "^1.6.0", + "get-tsconfig": "^4.10.0", + "unplugin-utils": "^0.2.4" + }, + "engines": { + "node": ">=14.18.0" + }, + "peerDependencies": { + "esbuild": ">=0.18.0", + "rollup": "^1.20.0 || ^2.0.0 || ^3.0.0 || ^4.0.0" + } + }, + "node_modules/rxjs": { + "version": "7.8.2", + "resolved": "https://registry.npmjs.org/rxjs/-/rxjs-7.8.2.tgz", + "integrity": "sha512-dhKf903U/PQZY6boNNtAGdWbG85WAbjT/1xYoZIC7FAY0yWapOBQVsVrDl58W86//e1VpMNBtRV4MaXfdMySFA==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "tslib": "^2.1.0" + } + }, "node_modules/sade": { "version": "1.8.1", "resolved": "https://registry.npmjs.org/sade/-/sade-1.8.1.tgz", @@ -1766,6 +2833,26 @@ "node": ">=6" } }, + "node_modules/scale-ts": { + "version": "1.6.1", + "resolved": "https://registry.npmjs.org/scale-ts/-/scale-ts-1.6.1.tgz", + "integrity": "sha512-PBMc2AWc6wSEqJYBDPcyCLUj9/tMKnLX70jLOSndMtcUoLQucP/DM0vnQo1wJAYjTrQiq8iG9rD0q6wFzgjH7g==", + "dev": true, + "license": "MIT" + }, + "node_modules/semver": { + "version": "7.8.5", + "resolved": "https://registry.npmjs.org/semver/-/semver-7.8.5.tgz", + "integrity": "sha512-Y7/KDsb8LjooZpwaqGyulO6DQlksgCncchHGk+sZIY4SBvUocMBEFH5Ur1fI4dV+Jvl0w6cjvucaIi40puRioA==", + "dev": true, + "license": "ISC", + "bin": { + "semver": "bin/semver.js" + }, + "engines": { + "node": ">=10" + } + }, "node_modules/set-cookie-parser": { "version": "3.1.4", "resolved": "https://registry.npmjs.org/set-cookie-parser/-/set-cookie-parser-3.1.4.tgz", @@ -1780,6 +2867,19 @@ "dev": true, "license": "ISC" }, + "node_modules/signal-exit": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/signal-exit/-/signal-exit-4.1.0.tgz", + "integrity": "sha512-bzyZ1e88w9O1iNJbKnOlvYTrWPDl46O1bG0D3XInv+9tkPrxrN8jUUTiFlDkkmKWgn1M6CfIA13SuGqOa9Korw==", + "dev": true, + "license": "ISC", + "engines": { + "node": ">=14" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, "node_modules/sirv": { "version": "3.0.2", "resolved": "https://registry.npmjs.org/sirv/-/sirv-3.0.2.tgz", @@ -1795,6 +2895,32 @@ "node": ">=18" } }, + "node_modules/smoldot": { + "version": "3.7.0", + "resolved": "https://registry.npmjs.org/smoldot/-/smoldot-3.7.0.tgz", + "integrity": "sha512-49/jSn4hVysTu6SL8wTk6JRedG1GhxgDkxQ9hEC9gKMadZvWIoxgBVS2N8OQn4UTpWAd3hpifiBh8tRYPh8EkQ==", + "dev": true, + "license": "GPL-3.0-or-later WITH Classpath-exception-2.0", + "dependencies": { + "ws": "^8.8.1" + } + }, + "node_modules/sort-keys": { + "version": "5.1.0", + "resolved": "https://registry.npmjs.org/sort-keys/-/sort-keys-5.1.0.tgz", + "integrity": "sha512-aSbHV0DaBcr7u0PVHXzM6NbZNAtrr9sF6+Qfs9UUVG7Ll3jQ6hHi8F/xqIIcn2rvIVbr0v/2zyjSdwSV47AgLQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "is-plain-obj": "^4.0.0" + }, + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/source-map-js": { "version": "1.2.2", "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.2.tgz", @@ -1805,6 +2931,42 @@ "node": ">=0.10.0" } }, + "node_modules/spdx-correct": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/spdx-correct/-/spdx-correct-3.2.0.tgz", + "integrity": "sha512-kN9dJbvnySHULIluDHy32WHRUu3Og7B9sbY7tsFLctQkIqnMh3hErYgdMjTYuqmcXX+lK5T1lnUt3G7zNswmZA==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "spdx-expression-parse": "^3.0.0", + "spdx-license-ids": "^3.0.0" + } + }, + "node_modules/spdx-exceptions": { + "version": "2.5.0", + "resolved": "https://registry.npmjs.org/spdx-exceptions/-/spdx-exceptions-2.5.0.tgz", + "integrity": "sha512-PiU42r+xO4UbUS1buo3LPJkjlO7430Xn5SVAhdpzzsPHsjbYVflnnFdATgabnLude+Cqu25p6N+g2lw/PFsa4w==", + "dev": true, + "license": "CC-BY-3.0" + }, + "node_modules/spdx-expression-parse": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/spdx-expression-parse/-/spdx-expression-parse-3.0.1.tgz", + "integrity": "sha512-cbqHunsQWnJNE6KhVSMsMeH5H/L9EpymbzqTQ3uLwNCLZ1Q481oWaofqH7nO6V07xlXwY6PhQdQ2IedWx/ZK4Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "spdx-exceptions": "^2.1.0", + "spdx-license-ids": "^3.0.0" + } + }, + "node_modules/spdx-license-ids": { + "version": "3.0.24", + "resolved": "https://registry.npmjs.org/spdx-license-ids/-/spdx-license-ids-3.0.24.tgz", + "integrity": "sha512-cLS9TtWkIQFyLkJ3/5aFQAOHOSKTlOs/7WDut/XPSdjom1fJhUdkepeJAKXa9Y05+FabHd0sti+Et559vDtkpQ==", + "dev": true, + "license": "CC0-1.0" + }, "node_modules/stackback": { "version": "0.0.2", "resolved": "https://registry.npmjs.org/stackback/-/stackback-0.0.2.tgz", @@ -1819,6 +2981,65 @@ "dev": true, "license": "MIT" }, + "node_modules/stdin-discarder": { + "version": "0.3.2", + "resolved": "https://registry.npmjs.org/stdin-discarder/-/stdin-discarder-0.3.2.tgz", + "integrity": "sha512-eCPu1qRxPVkl5605OTWF8Wz40b4Mf45NY5LQmVPQ599knfs5QhASUm9GbJ5BDMDOXgrnh0wyEdvzmL//YMlw0A==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/string-width": { + "version": "8.3.0", + "resolved": "https://registry.npmjs.org/string-width/-/string-width-8.3.0.tgz", + "integrity": "sha512-ZbmZM0JCihQN91dWnxoipT2KOEyHqEyfRXUyjuRhW8b/xnqPDoq4gWEVApTVa9db2wN8mmoikgFBbjh71+cGeQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "get-east-asian-width": "^1.5.0", + "strip-ansi": "^7.1.2" + }, + "engines": { + "node": ">=20" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/strip-ansi": { + "version": "7.2.0", + "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-7.2.0.tgz", + "integrity": "sha512-yDPMNjp4WyfYBkHnjIRLfca1i6KMyGCtsVgoKe/z1+6vukgaENdgGBZt+ZmKPc4gavvEZ5OgHfHdrazhgNyG7w==", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-regex": "^6.2.2" + }, + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/chalk/strip-ansi?sponsor=1" + } + }, + "node_modules/strip-final-newline": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/strip-final-newline/-/strip-final-newline-4.0.0.tgz", + "integrity": "sha512-aulFJcD6YK8V1G7iRB5tigAP4TsHBZZrOV8pjV++zdUwmeV8uzbY7yn6h9MswN62adStNZFuCIx4haBnRuMDaw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/strip-literal": { "version": "3.1.0", "resolved": "https://registry.npmjs.org/strip-literal/-/strip-literal-3.1.0.tgz", @@ -1884,6 +3105,19 @@ "typescript": "^5.0.0 || ^6.0.0" } }, + "node_modules/tagged-tag": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/tagged-tag/-/tagged-tag-1.0.0.tgz", + "integrity": "sha512-yEFYrVhod+hdNyx7g5Bnkkb0G6si8HJurOoOEgC8B/O0uXLHlaey/65KRv6cuWBNhBgHKAROVpc7QyYqE5gFng==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=20" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/tinybench": { "version": "2.9.0", "resolved": "https://registry.npmjs.org/tinybench/-/tinybench-2.9.0.tgz", @@ -1955,6 +3189,42 @@ "node": ">=6" } }, + "node_modules/tsc-prog": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/tsc-prog/-/tsc-prog-2.3.0.tgz", + "integrity": "sha512-ycET2d75EgcX7y8EmG4KiZkLAwUzbY4xRhA6NU0uVbHkY4ZjrAAuzTMxXI85kOwATqPnBI5C/7y7rlpY0xdqHA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + }, + "peerDependencies": { + "typescript": ">=4" + } + }, + "node_modules/tslib": { + "version": "2.8.1", + "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz", + "integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==", + "dev": true, + "license": "0BSD" + }, + "node_modules/type-fest": { + "version": "5.10.0", + "resolved": "https://registry.npmjs.org/type-fest/-/type-fest-5.10.0.tgz", + "integrity": "sha512-NoSdpq/WEiAg5sjmBkmV/hfxv6HJH4NqPNrqjtSO5CwRmpsDfaf4begxW34KdJykH/l1yHtwBWQkCRdoXO8mPA==", + "dev": true, + "license": "(MIT OR CC0-1.0)", + "dependencies": { + "tagged-tag": "^1.0.0" + }, + "engines": { + "node": ">=20" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/typescript": { "version": "5.9.3", "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz", @@ -1976,6 +3246,54 @@ "dev": true, "license": "MIT" }, + "node_modules/unicorn-magic": { + "version": "0.3.0", + "resolved": "https://registry.npmjs.org/unicorn-magic/-/unicorn-magic-0.3.0.tgz", + "integrity": "sha512-+QBBXBCvifc56fsbuxZQ6Sic3wqqc3WWaqxs58gvJrcOuN83HGTCwz3oS5phzU9LthRNE9VrJCFCLUgHeeFnfA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/unplugin-utils": { + "version": "0.2.5", + "resolved": "https://registry.npmjs.org/unplugin-utils/-/unplugin-utils-0.2.5.tgz", + "integrity": "sha512-gwXJnPRewT4rT7sBi/IvxKTjsms7jX7QIDLOClApuZwR49SXbrB1z2NLUZ+vDHyqCj/n58OzRRqaW+B8OZi8vg==", + "dev": true, + "license": "MIT", + "dependencies": { + "pathe": "^2.0.3", + "picomatch": "^4.0.3" + }, + "engines": { + "node": ">=18.12.0" + }, + "funding": { + "url": "https://github.com/sponsors/sxzz" + } + }, + "node_modules/uqr": { + "version": "0.1.3", + "resolved": "https://registry.npmjs.org/uqr/-/uqr-0.1.3.tgz", + "integrity": "sha512-0rjE8iEJe4YmT9TOhwsZtqCMRLc5DXZUI2UEYUUg63ikBkqqE5EYWaI0etFe/5KUcmcYwLih2RND1kq+hrUJXA==", + "dev": true, + "license": "MIT" + }, + "node_modules/validate-npm-package-license": { + "version": "3.0.4", + "resolved": "https://registry.npmjs.org/validate-npm-package-license/-/validate-npm-package-license-3.0.4.tgz", + "integrity": "sha512-DpKm2Ui/xN7/HQKCtpZxoRWBhZ9Z0kqtygG8XCgNQ8ZlDnxuQmWhj566j8fN4Cu3/JmbhsDo7fcAJq4s9h27Ew==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "spdx-correct": "^3.0.0", + "spdx-expression-parse": "^3.0.0" + } + }, "node_modules/vite": { "version": "7.3.7", "resolved": "https://registry.npmjs.org/vite/-/vite-7.3.7.tgz", @@ -2167,6 +3485,22 @@ } } }, + "node_modules/which-command": { + "version": "0.1.0", + "resolved": "https://registry.npmjs.org/which-command/-/which-command-0.1.0.tgz", + "integrity": "sha512-XZyoF5/5hZtXitIwzrU4NKK+Wtbb9aB9CezUEw2Q0wlYK8NUYQxC1rRXgNueYLtBAJwXIb+/tFVk4dozciNJMA==", + "dev": true, + "license": "MIT", + "bin": { + "which-command": "cli.js" + }, + "engines": { + "node": ">=22" + }, + "funding": { + "url": "https://github.com/sindresorhus/which-command?sponsor=1" + } + }, "node_modules/why-is-node-running": { "version": "2.3.0", "resolved": "https://registry.npmjs.org/why-is-node-running/-/why-is-node-running-2.3.0.tgz", @@ -2184,6 +3518,175 @@ "node": ">=8" } }, + "node_modules/write-file-atomic": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/write-file-atomic/-/write-file-atomic-5.0.1.tgz", + "integrity": "sha512-+QU2zd6OTD8XWIJCbffaiQeH9U73qIqafo1x6V1snCWYGJf6cVE0cDR4D8xRzcEnfI21IFrUPzPGtcPf8AC+Rw==", + "dev": true, + "license": "ISC", + "dependencies": { + "imurmurhash": "^0.1.4", + "signal-exit": "^4.0.1" + }, + "engines": { + "node": "^14.17.0 || ^16.13.0 || >=18.0.0" + } + }, + "node_modules/write-json-file": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/write-json-file/-/write-json-file-6.0.0.tgz", + "integrity": "sha512-MNHcU3f9WxnNyR6MxsYSj64Jz0+dwIpisWKWq9gqLj/GwmA9INg3BZ3vt70/HB3GEwrnDQWr4RPrywnhNzmUFA==", + "dev": true, + "license": "MIT", + "dependencies": { + "detect-indent": "^7.0.1", + "is-plain-obj": "^4.1.0", + "sort-keys": "^5.0.0", + "write-file-atomic": "^5.0.1" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/write-package": { + "version": "7.2.0", + "resolved": "https://registry.npmjs.org/write-package/-/write-package-7.2.0.tgz", + "integrity": "sha512-uMQTubF/vcu+Wd0b5BGtDmiXePd/+44hUWQz2nZPbs92/BnxRo74tqs+hqDo12RLiEd+CXFKUwxvvIZvtt34Jw==", + "dev": true, + "license": "MIT", + "dependencies": { + "deepmerge-ts": "^7.1.0", + "read-pkg": "^9.0.1", + "sort-keys": "^5.0.0", + "type-fest": "^4.23.0", + "write-json-file": "^6.0.0" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/write-package/node_modules/hosted-git-info": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/hosted-git-info/-/hosted-git-info-7.0.2.tgz", + "integrity": "sha512-puUZAUKT5m8Zzvs72XWy3HtvVbTWljRE66cP60bxJzAqf2DgICo7lYTY2IHUmLnNpjYvw5bvmoHvPc0QO2a62w==", + "dev": true, + "license": "ISC", + "dependencies": { + "lru-cache": "^10.0.1" + }, + "engines": { + "node": "^16.14.0 || >=18.0.0" + } + }, + "node_modules/write-package/node_modules/lru-cache": { + "version": "10.4.3", + "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-10.4.3.tgz", + "integrity": "sha512-JNAzZcXrCt42VGLuYz0zfAzDfAvJWW6AfYlDBQyDV5DClI2m5sAmK+OIO7s59XfsRsWHp02jAJrRadPRGTt6SQ==", + "dev": true, + "license": "ISC" + }, + "node_modules/write-package/node_modules/normalize-package-data": { + "version": "6.0.2", + "resolved": "https://registry.npmjs.org/normalize-package-data/-/normalize-package-data-6.0.2.tgz", + "integrity": "sha512-V6gygoYb/5EmNI+MEGrWkC+e6+Rr7mTmfHrxDbLzxQogBkgzo76rkok0Am6thgSF7Mv2nLOajAJj5vDJZEFn7g==", + "dev": true, + "license": "BSD-2-Clause", + "dependencies": { + "hosted-git-info": "^7.0.0", + "semver": "^7.3.5", + "validate-npm-package-license": "^3.0.4" + }, + "engines": { + "node": "^16.14.0 || >=18.0.0" + } + }, + "node_modules/write-package/node_modules/read-pkg": { + "version": "9.0.1", + "resolved": "https://registry.npmjs.org/read-pkg/-/read-pkg-9.0.1.tgz", + "integrity": "sha512-9viLL4/n1BJUCT1NXVTdS1jtm80yDEgR5T4yCelII49Mbj0v1rZdKqj7zCiYdbB0CuCgdrvHcNogAKTFPBocFA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/normalize-package-data": "^2.4.3", + "normalize-package-data": "^6.0.0", + "parse-json": "^8.0.0", + "type-fest": "^4.6.0", + "unicorn-magic": "^0.1.0" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/write-package/node_modules/type-fest": { + "version": "4.41.0", + "resolved": "https://registry.npmjs.org/type-fest/-/type-fest-4.41.0.tgz", + "integrity": "sha512-TeTSQ6H5YHvpqVwBRcnLDCBnDOHWYu7IvGbHT6N8AOymcr9PJGjc1GTtiWZTYg0NCgYwvnYWEkVChQAr9bjfwA==", + "dev": true, + "license": "(MIT OR CC0-1.0)", + "engines": { + "node": ">=16" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/write-package/node_modules/unicorn-magic": { + "version": "0.1.0", + "resolved": "https://registry.npmjs.org/unicorn-magic/-/unicorn-magic-0.1.0.tgz", + "integrity": "sha512-lRfVq8fE8gz6QMBuDM6a+LO3IAzTi05H6gCVaUpir2E1Rwpo4ZUog45KpNXKC/Mn3Yb9UDuHumeFTo9iV/D9FQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/ws": { + "version": "8.22.0", + "resolved": "https://registry.npmjs.org/ws/-/ws-8.22.0.tgz", + "integrity": "sha512-Ydggc987+RO0AnWtZ/7Wq9FtNvcrL1b/RO0ud9mWjUPgDrsAAwQSF51sm2hm1XofbU/4jkpGEsLFsZZxU+1DOg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10.0.0" + }, + "peerDependencies": { + "bufferutil": "^4.0.1", + "utf-8-validate": ">=5.0.2" + }, + "peerDependenciesMeta": { + "bufferutil": { + "optional": true + }, + "utf-8-validate": { + "optional": true + } + } + }, + "node_modules/yoctocolors": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/yoctocolors/-/yoctocolors-2.2.0.tgz", + "integrity": "sha512-xYqdZFUK/VYazNl/oCDYN+3WloWQwMfZxBoiNt6qNyk+xfOdi598muWE42rNZFp1kNOiqW936q5RhUdnpqElSg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/zimmerframe": { "version": "1.1.5", "resolved": "https://registry.npmjs.org/zimmerframe/-/zimmerframe-1.1.5.tgz", diff --git a/web/package.json b/web/package.json index 81f494e..749ea07 100644 --- a/web/package.json +++ b/web/package.json @@ -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" } diff --git a/web/src/lib/basket.ts b/web/src/lib/basket.ts new file mode 100644 index 0000000..cb37a23 --- /dev/null +++ b/web/src/lib/basket.ts @@ -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; + +/** 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): { 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, + }; +} diff --git a/web/src/lib/components/BallotPanel.svelte b/web/src/lib/components/BallotPanel.svelte new file mode 100644 index 0000000..ed10023 --- /dev/null +++ b/web/src/lib/components/BallotPanel.svelte @@ -0,0 +1,306 @@ + + +{#snippet bulk(key: string | null, label: string)} +
+ + + +
+{/snippet} + +
+

{t('basket.title')}

+

{t('basket.intro')}

+ {#if access && !access.open} +

{t(access.reason === 'chain_off' ? 'vote.chain.off' : 'vote.members')}

+ {/if} + +
+ {t('basket.doc')} + {@render bulk('*', t('basket.doc'))} +
+ {#if left.length} +

{i18n.plural('basket.waiting', left.length)}

+ {/if} + +
+ {#each all as s, i (s.key ?? `none-${i}`)} + {@const label = sectionLabel(s.key)} +
+ {#if label} +
+

{label}

+ {@render bulk(s.key, label)} +
+ {/if} +
    + {#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]} +
  • + + {#if several}

    {t('basket.versions')}

    {/if} + {#each [...slot.versions, ...slot.inertia] as v (v.id)} +
    + + {labelOf(v, slot)} + + {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} + + + basket.pick(v.id, c)} + /> +
    + {/each} +
  • + {/each} +
+
+ {/each} +
+ +
+

+ {#if tally.total}{i18n.plural('basket.count', tally.total)}{:else}{t('basket.empty')}{/if} +

+ +

+ {#if access?.way === 'chain' && access.chain}{t('basket.note.chain', { amount: g1Amount(access.chain.amount) })}{:else}{t('basket.note.server')}{/if} +

+ {#if error}{/if} +
+
+ + diff --git a/web/src/lib/components/ChainSign.svelte b/web/src/lib/components/ChainSign.svelte new file mode 100644 index 0000000..4db3321 --- /dev/null +++ b/web/src/lib/components/ChainSign.svelte @@ -0,0 +1,421 @@ + + + !closed && close()}> +

{t('sign.title')}

+

{i18n.plural('sign.summary', lines.length, { amount })}

+ +
+ {#if step.kind === 'start' || step.kind === 'opening'} +

{t('sign.opening')}

+ {:else if step.kind === 'none'} +

{t('sign.none')}

+ {:else if step.kind === 'wallets'} +

{t('sign.wallet')}

+
    + {#each step.list as w (w.id)} +
  • + {/each} +
+ {:else if step.kind === 'accounts'} + {@const wallet = step.wallet} +

{t('sign.account')}

+
    + {#each step.list as a (a.address)} +
  • + +
  • + {/each} +
+ {:else if step.kind === 'signing'} +

{t('sign.signing')}

+ {:else if step.kind === 'waiting'} +

{t('sign.waiting')}

+

{t('sign.later')}

+ {:else if step.kind === 'gecko' && ballot} +

{t('sign.gecko.scan')}

+
+ + + +
+
{t('sign.gecko.amount')}
+
{amount} Ğ1
+
{t('sign.gecko.comment')}
+
{ballot.comment}
+
+
+ {t('sign.gecko.open')} +

{t('sign.waiting')}

+

{t('sign.later')}

+ {:else if step.kind === 'confirmed'} +

{t('sign.confirmed')}

+ {#if step.voter}

{t('sign.confirmed.as', { name: step.voter })}

{/if} + {:else if step.kind === 'refused'} + + {:else if step.kind === 'expired'} + + {:else if step.kind === 'failed'} + + {/if} +
+ +
+ {#if geckoOffered} + + {/if} + {#if step.kind === 'failed' || step.kind === 'expired' || step.kind === 'refused'} + + {/if} + +
+
+ + diff --git a/web/src/lib/components/ChoicePicker.svelte b/web/src/lib/components/ChoicePicker.svelte new file mode 100644 index 0000000..f6d8971 --- /dev/null +++ b/web/src/lib/components/ChoicePicker.svelte @@ -0,0 +1,73 @@ + + + + + + {#if voted || value === 'none'} + + {/if} + + + diff --git a/web/src/lib/components/DeepList.svelte b/web/src/lib/components/DeepList.svelte index ea00f7a..ac68677 100644 --- a/web/src/lib/components/DeepList.svelte +++ b/web/src/lib/components/DeepList.svelte @@ -1,8 +1,9 @@ -