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..3a1db7b --- /dev/null +++ b/crates/ld-server/src/ballots.rs @@ -0,0 +1,639 @@ +//! The vote, grouped: the whole document, a section or an article in one +//! gesture (Yvv, 2026-10-30: « Je peux voter tout d'un coup, ou une section +//! entière, ou un article avec son titre, ou séparément »). +//! +//! - In a **managed group** (essai, Axiom…), the votes are written on the +//! server in one transaction, as before: the test group stays easy to try. +//! - Where the electorate is the **Ğ1 web of trust** (Monnaie libre), a vote is +//! one transaction on the chain, signed by the person's wallet (ADR 0008): +//! the server seals a **pending ballot** (`ld_engine::ballot`), the page has +//! it signed and sent, and the server **confirms** it by reading the indexer: +//! the remark, the transfer of the amount to the vote account in the same +//! transaction, the signer's identity and its membership — now. The vote +//! counts for the identity `idty:` 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? }))) +} + +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). +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 = ? 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..4074829 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)) .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..9f0e269 --- /dev/null +++ b/crates/ld-server/tests/ballots.rs @@ -0,0 +1,787 @@ +//! The vote, grouped, and the vote on the Ğ1 chain (ADR 0008): sealed by the +//! server, confirmed by reading an indexer — here a small simulated indexer that +//! answers the GraphQL query in the shape of the real one +//! (`tests/fixtures/indexer-merci.json`, relevé on indexer.duniter.org). + +use axum::body::Body; +use axum::extract::State; +use axum::http::{Request, StatusCode, header}; +use axum::response::{IntoResponse, Response}; +use axum::routing::post; +use axum::{Json, Router}; +use http_body_util::BodyExt; +use ld_engine::address::{self, G1_PREFIX}; +use ld_engine::ballot::{blake2_256, to_hex}; +use ld_server::config::VoteChain; +use ld_server::{AppState, CSRF_HEADER, CSRF_VALUE, Config, app, ballots, open_db, seed}; +use serde_json::{Value, json}; +use std::sync::{Arc, Mutex}; +use tower::ServiceExt; + +// Substrate's well-known development keys, as Ğ1 addresses: no real account is touched. +const VOTE_ACCOUNT: &str = "g1L5EYvvSBaYuQCPW78bHxYcvHfmWAqHYe3Gsrp9SV4kWZaEM"; // Dave +const ALICE: &str = "g1Pn1geRZnHcVdWdKkoYwQrAARDTXK1D7H3zuXj7GNayT1thu"; +const ALICE_GENERIC: &str = "5GrwvaEF5zXb26Fz9rcQpDWS57CtERHpNehXCPcNoHGKutQY"; +const BOB: &str = "g1NCrQaFReZcbUW8pKsazfWFCKNSdrTomnDraUMybJc4DFF5g"; +const CHARLIE: &str = "g1NFWUkPTyZWE77YUrfBKfAhV9W81qmgdD17v1NGhb1EjYFqi"; +const OTHER: &str = "g1QBoGEqZuxL77pF4rMa25f1cH6HAxwgBq66zZu76JPqsaVgj"; // Eve + +// --------------------------------------------------------------------------- +// A simulated indexer +// --------------------------------------------------------------------------- + +#[derive(Default)] +struct Chain { + comments: Vec, + 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_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 3904c68..ade7045 100644 --- a/crates/ld-server/tests/inertia.rs +++ b/crates/ld-server/tests/inertia.rs @@ -480,9 +480,24 @@ async fn e1_and_e2_go_to_level_4_once_and_an_open_change_stays_open_voted_at_4() let at = if *code == "E1" { 4 } else { 3 }; assert_eq!(d["proposal"]["votedAtInertia"], at, "{group} {code}"); } - // And votable: in Monnaie libre, judged at level 4. + // And votable: in Monnaie libre, judged at level 4. A vote there is confirmed on the + // chain (tests/ballots.rs); here, one such vote is written as the confirmation writes it. let (_, _, e1_ml) = &proposals[1]; - c.vote(e1_ml, "for").await; + sqlx::query( + "INSERT INTO identities (id, kind, display_name, g1_member, admin, created_at) + VALUES ('idty:9001', 'g1', 'Alice', 1, 0, '2026-10-30T00:00:00Z')", + ) + .execute(&pool) + .await + .expect("identity"); + sqlx::query( + "INSERT INTO votes (proposal_id, identity_id, choice, revision, cast_at, source, block, event_index) + VALUES (?, 'idty:9001', 'for', 1, '2026-10-30T00:00:00Z', 'chain', 100, 1)", + ) + .bind(e1_ml) + .execute(&pool) + .await + .expect("vote"); let d = c.get(&format!("/api/proposals/{e1_ml}")).await; let ratio = d["proposal"]["evaluation"]["requiredRatio"].as_f64().expect("ratio"); let p = d["participation"]["ratio"].as_f64().expect("p"); 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]