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