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

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Yvv
2026-10-31 00:15:46 +01:00
co-authored by Claude Opus 5.5
59 changed files with 7228 additions and 370 deletions
+664
View File
@@ -0,0 +1,664 @@
//! The vote, grouped: the whole document, a section or an article in one
//! gesture (Yvv, 2026-10-30: « Je peux voter tout d'un coup, ou une section
//! entière, ou un article avec son titre, ou séparément »).
//!
//! - In a **managed group** (essai, Axiom…), the votes are written on the
//! server in one transaction, as before: the test group stays easy to try.
//! - Where the electorate is the **Ğ1 web of trust** (Monnaie libre), a vote is
//! one transaction on the chain, signed by the person's wallet (ADR 0008):
//! the server seals a **pending ballot** (`ld_engine::ballot`), the page has
//! it signed and sent, and the server **confirms** it by reading the indexer:
//! the remark, the transfer of the amount to the vote account in the same
//! transaction, the signer's identity and its membership — now. The vote
//! counts for the identity `idty:<index>` of the signer, created if need be;
//! the session that asked for the ballot sees it as « ton vote ».
//! - **The last one stands**, per proposal and per identity, in the order of
//! the chain: a ballot confirmed late never overrides a more recent one.
//!
//! A background task reads the indexer for pending ballots every 10 seconds
//! while there are some, lets them expire after 24 hours, and runs the
//! adoption pass after each confirmation.
use crate::AppState;
use crate::adoption::{self, Entered};
use crate::auth::{IdentityRow, User};
use crate::docs::{self, DocState};
use crate::error::{AppError, AppResult};
use crate::indexer::{self, Found, Reading};
use crate::util;
use axum::Json;
use axum::extract::{Path, State};
use ld_engine::address;
use ld_engine::ballot::{self, ChainPosition, Choice, Expected, Verdict};
use rand::RngCore;
use serde::Deserialize;
use serde_json::json;
use std::collections::{HashMap, HashSet};
use std::time::Duration;
use tokio::sync::Mutex;
use trustin_access::VoteWay;
/// Every article of a long document, with room to spare.
const MAX_LINES: usize = 200;
/// Pending ballots one session may hold at once.
const MAX_PENDING: i64 = 20;
/// A ballot not seen on the chain within this time is let go.
pub const EXPIRES_HOURS: i64 = 24;
/// The page asks often; the indexer is read at most this often per ballot.
const RECHECK_SECS: i64 = 3;
const WATCH_EVERY: Duration = Duration::from_secs(10);
/// Pending ballots read per pass of the background task.
const PER_PASS: i64 = 50;
/// One confirmation at a time: « the last one stands » reads the other ballots of
/// the identity, then writes; two at once could both read before either writes.
static CONFIRM: Mutex<()> = Mutex::const_new(());
#[derive(Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct LineBody {
proposal_id: String,
/// `for`, `against`, or null to withdraw.
choice: Option<String>,
}
#[derive(Deserialize)]
pub struct VotesBody {
lines: Vec<LineBody>,
}
#[derive(Deserialize)]
pub struct BallotBody {
lines: Vec<LineBody>,
/// The account that will sign, in any SS58 form, when the page knows it.
author: Option<String>,
}
/// A line checked against the document: a votable version, its revision now.
pub(crate) struct Cast {
pub proposal_id: String,
pub revision: i64,
pub choice: Choice,
}
fn parse_choice(raw: Option<&str>) -> AppResult<Choice> {
match raw {
None | Some("none") => Ok(Choice::None),
Some(c) => Choice::parse(c).ok_or(AppError::Invalid("choice")),
}
}
/// The lines of a vote, read against the document: at least one, each version
/// once, each one of this document and votable.
fn casts(st: &DocState, lines: &[LineBody]) -> AppResult<Vec<Cast>> {
if lines.is_empty() || lines.len() > MAX_LINES {
return Err(AppError::Invalid("lines"));
}
let mut seen = HashSet::new();
let mut out = Vec::with_capacity(lines.len());
for l in lines {
if !seen.insert(l.proposal_id.as_str()) {
return Err(AppError::Invalid("lines"));
}
let p = st.proposals.iter().find(|p| p.id == l.proposal_id).ok_or(AppError::Invalid("lines"))?;
if !matches!(p.status.as_str(), "open" | "in_force") {
return Err(AppError::Conflict("not_votable"));
}
out.push(Cast { proposal_id: p.id.clone(), revision: p.revision, choice: parse_choice(l.choice.as_deref())? });
}
Ok(out)
}
/// Writes votes on the server, all or none.
pub(crate) async fn write_server_votes(state: &AppState, identity: &str, casts: &[Cast]) -> AppResult<()> {
let mut tx = state.db.begin().await?;
let now = util::now();
for c in casts {
match c.choice {
Choice::None => {
sqlx::query("DELETE FROM votes WHERE proposal_id = ? AND identity_id = ?")
.bind(&c.proposal_id)
.bind(identity)
.execute(&mut *tx)
.await?;
}
choice => {
sqlx::query(
"INSERT INTO votes (proposal_id, identity_id, choice, revision, cast_at, source) VALUES (?, ?, ?, ?, ?, 'server')
ON CONFLICT(proposal_id, identity_id) DO UPDATE SET choice = excluded.choice,
revision = excluded.revision, cast_at = excluded.cast_at, source = 'server',
block = NULL, event_index = NULL, ballot_id = NULL",
)
.bind(&c.proposal_id)
.bind(identity)
.bind(choice.as_str())
.bind(c.revision)
.bind(&now)
.execute(&mut *tx)
.await?;
}
}
}
tx.commit().await?;
Ok(())
}
/// `POST /api/docs/{id}/votes` — a grouped vote in a managed group: one write.
pub async fn vote_many(
State(state): State<AppState>,
User(user): User,
Path(doc_id): Path<String>,
Json(b): Json<VotesBody>,
) -> AppResult<Json<serde_json::Value>> {
let st = docs::load_state(&state, &doc_id).await?;
docs::require_open(&state, &user, &st.group).await?;
if trustin_access::vote_way(&st.group.to_group()) == VoteWay::Chain {
return Err(AppError::Conflict("chain_only"));
}
if !docs::may_vote(&state, &user, &st.group).await? {
return Err(AppError::Forbidden);
}
let casts = casts(&st, &b.lines)?;
write_server_votes(&state, &user.id, &casts).await?;
let entered = adoption::run(&state, &st.doc.id).await?;
Ok(Json(json!({ "count": casts.len(), "entered": entered })))
}
// ---------------------------------------------------------------------------
// Ballots of the chain
// ---------------------------------------------------------------------------
#[derive(Clone, Debug, sqlx::FromRow)]
pub struct BallotRow {
pub id: String,
pub doc_id: String,
pub requested_by: String,
pub author: Option<String>,
pub digest: String,
pub remark_hash: String,
pub account: String,
pub amount: i64,
pub status: String,
pub reason: Option<String>,
pub voter_id: Option<String>,
pub block: Option<i64>,
pub entered: Option<String>,
pub created_at: String,
pub expires_at: String,
pub checked_at: Option<String>,
}
const BALLOT_SELECT: &str = "SELECT id, doc_id, requested_by, author, digest, remark_hash, account, amount, status,
reason, voter_id, block, entered, created_at, expires_at, checked_at FROM ballots";
impl BallotRow {
pub fn comment(&self) -> String {
format!("{}{}", ballot::PREFIX, self.digest)
}
}
fn salt() -> String {
let mut bytes = [0u8; 16];
rand::rng().fill_bytes(&mut bytes);
hex::encode(bytes)
}
/// `POST /api/docs/{id}/ballots` — seal a ballot for the wallet to sign.
pub async fn create(
State(state): State<AppState>,
User(user): User,
Path(doc_id): Path<String>,
Json(b): Json<BallotBody>,
) -> AppResult<Json<serde_json::Value>> {
let st = docs::load_state(&state, &doc_id).await?;
docs::require_open(&state, &user, &st.group).await?;
let group = st.group.to_group();
if trustin_access::vote_way(&group) == VoteWay::Server {
return Err(AppError::Conflict("server_vote"));
}
let granted = docs::granted(&state, &group.slug, &user.id).await?;
if !trustin_access::asks_ballot(&user.to_identity(), &group, granted) {
return Err(AppError::Forbidden);
}
let chain = state.cfg.vote.as_ref().ok_or(AppError::Conflict("chain_vote_off"))?;
let casts = casts(&st, &b.lines)?;
let author = match b.author.as_deref().map(str::trim).filter(|a| !a.is_empty()) {
Some(a) => Some(address::to_g1(a).ok_or(AppError::Invalid("author"))?),
None => None,
};
let pending: i64 = sqlx::query_scalar(
"SELECT COUNT(*) FROM ballots WHERE requested_by = ? AND status = 'pending' AND expires_at > ?",
)
.bind(&user.id)
.bind(util::now())
.fetch_one(&state.db)
.await?;
if pending >= MAX_PENDING {
return Err(AppError::Conflict("too_many_ballots"));
}
let lines: Vec<ballot::Line> = casts
.iter()
.map(|c| ballot::Line {
proposal_id: c.proposal_id.clone(),
revision: u32::try_from(c.revision).unwrap_or(u32::MAX),
choice: c.choice,
})
.collect();
let salt = salt();
let sealed = ballot::seal(&st.doc.id, author.as_deref(), &lines, &salt).map_err(|_| AppError::Invalid("lines"))?;
let id = util::id();
let now = util::now();
let expires = util::in_seconds(EXPIRES_HOURS * 3600);
let mut tx = state.db.begin().await?;
sqlx::query(
"INSERT INTO ballots (id, doc_id, requested_by, author, salt, digest, remark_hash, account, amount, status,
created_at, expires_at)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, 'pending', ?, ?)",
)
.bind(&id)
.bind(&st.doc.id)
.bind(&user.id)
.bind(&author)
.bind(&salt)
.bind(&sealed.digest)
.bind(&sealed.remark_hash)
.bind(&chain.account)
.bind(i64::try_from(chain.amount).unwrap_or(i64::MAX))
.bind(&now)
.bind(&expires)
.execute(&mut *tx)
.await?;
for c in &casts {
sqlx::query("INSERT INTO ballot_lines (ballot_id, proposal_id, revision, choice) VALUES (?, ?, ?, ?)")
.bind(&id)
.bind(&c.proposal_id)
.bind(c.revision)
.bind(c.choice.as_str())
.execute(&mut *tx)
.await?;
}
tx.commit().await?;
Ok(Json(json!({ "ballot": {
"id": id, "status": "pending", "comment": sealed.comment, "account": chain.account,
"amount": chain.amount, "lines": casts.len(), "author": author, "expiresAt": expires,
} })))
}
async fn load(state: &AppState, id: &str) -> AppResult<BallotRow> {
sqlx::query_as::<_, BallotRow>(&format!("{BALLOT_SELECT} WHERE id = ?"))
.bind(id)
.fetch_optional(&state.db)
.await?
.ok_or(AppError::NotFound)
}
/// `GET /api/ballots/{id}` — where a ballot stands, for the session that asked
/// for it. A pending ballot is looked for on the indexer at most every few seconds.
pub async fn status(
State(state): State<AppState>,
User(user): User,
Path(id): Path<String>,
) -> AppResult<Json<serde_json::Value>> {
let mut row = load(&state, &id).await?;
if row.requested_by != user.id {
return Err(AppError::NotFound);
}
if row.status == "pending" && row.checked_at.as_ref().is_none_or(|c| *c < util::in_seconds(-RECHECK_SECS)) {
check(&state, &row).await?;
row = load(&state, &id).await?;
}
Ok(Json(json!({ "ballot": view(&state, &row).await? })))
}
/// `DELETE /api/ballots/{id}` — the wallet refused to sign, or could not pay:
/// nothing left for the chain, so the ballot is let go at once rather than shown
/// as waiting for a day. Only a pending ballot, only by the session that asked.
pub async fn drop(
State(state): State<AppState>,
User(user): User,
Path(id): Path<String>,
) -> AppResult<Json<serde_json::Value>> {
let row = load(&state, &id).await?;
if row.requested_by != user.id {
return Err(AppError::NotFound);
}
sqlx::query(
"UPDATE ballots SET status = 'expired', reason = 'dropped', settled_at = ? WHERE id = ? AND status = 'pending'",
)
.bind(util::now())
.bind(&id)
.execute(&state.db)
.await?;
let row = load(&state, &id).await?;
Ok(Json(json!({ "ballot": view(&state, &row).await? })))
}
async fn view(state: &AppState, row: &BallotRow) -> AppResult<serde_json::Value> {
let voter: Option<String> = match &row.voter_id {
Some(v) => {
sqlx::query_scalar("SELECT display_name FROM identities WHERE id = ?")
.bind(v)
.fetch_optional(&state.db)
.await?
}
None => None,
};
let entered: serde_json::Value =
row.entered.as_deref().and_then(|e| serde_json::from_str(e).ok()).unwrap_or_else(|| json!([]));
Ok(json!({
"id": row.id, "status": row.status, "reason": row.reason, "comment": row.comment(),
"account": row.account, "amount": row.amount, "author": row.author, "block": row.block,
"voter": voter, "entered": entered, "expiresAt": row.expires_at,
}))
}
/// Reads the indexer for one pending ballot and settles it if the chain has spoken.
pub async fn check(state: &AppState, row: &BallotRow) -> AppResult<()> {
let now = util::now();
if row.expires_at <= now {
expire(state).await?;
return Ok(());
}
let reading = indexer::comments(state, &row.remark_hash).await;
sqlx::query("UPDATE ballots SET checked_at = ? WHERE id = ?").bind(&now).bind(&row.id).execute(&state.db).await?;
let Reading::Found(list) = reading else {
tracing::debug!(ballot = %row.id, "no indexer answered: the ballot stays pending");
return Ok(());
};
let comment = row.comment();
let list: Vec<Found> = list.into_iter().filter(|f| f.remark == comment).collect();
let (Some((_, account)), Some(amount)) = (address::decode(&row.account), u64::try_from(row.amount).ok()) else {
return Err(anyhow::anyhow!("ballot {} has an unreadable account or amount", row.id).into());
};
let expected =
Expected { account, amount, author: row.author.as_deref().and_then(address::decode).map(|(_, k)| k) };
let comments: Vec<ballot::Comment> = list.iter().map(|f| f.comment.clone()).collect();
let Some((position, verdict)) = ballot::judge(&comments, &expected) else { return Ok(()) };
let Some(found) = list.iter().find(|f| f.comment.position == position) else { return Ok(()) };
match verdict {
// The right to vote is checked now, at confirmation, by trustinWallet's rule.
Verdict::Paid { identity } if trustin_access::chain_vote_counts(identity.member) => {
confirm(state, row, found, identity.index).await
}
Verdict::Paid { .. } => refuse(state, row, found, "not_member").await,
other => refuse(state, row, found, other.reason().unwrap_or("refused")).await,
}
}
async fn refuse(state: &AppState, row: &BallotRow, found: &Found, reason: &str) -> AppResult<()> {
let done = sqlx::query(
"UPDATE ballots SET status = 'refused', reason = ?, signer = ?, block = ?, event_index = ?, event_id = ?,
settled_at = ? WHERE id = ? AND status = 'pending'",
)
.bind(reason)
.bind(&found.signer)
.bind(i64::from(found.comment.position.block))
.bind(i64::from(found.comment.position.event))
.bind(&found.event_id)
.bind(util::now())
.bind(&row.id)
.execute(&state.db)
.await?;
if done.rows_affected() == 1 {
tracing::info!(ballot = %row.id, reason, "ballot refused");
}
Ok(())
}
/// The ballot counts: written for the identity of the signer, line by line,
/// where no more recent ballot of that identity decided already.
async fn confirm(state: &AppState, row: &BallotRow, found: &Found, index: u32) -> AppResult<()> {
let voter = format!("idty:{index}");
let position = found.comment.position;
let now = util::now();
{
let _one = CONFIRM.lock().await;
let mut tx = state.db.begin().await?;
// The identity of the signer, as the chain has it now: a member (just
// checked), with its name and its account. Undone with the rest if the
// ballot was settled in the meantime.
let admin = state.cfg.admin_g1_ids.contains(&voter);
sqlx::query(
"INSERT INTO identities (id, kind, display_name, address, g1_member, admin, created_at)
VALUES (?, 'g1', ?, ?, 1, ?, ?)
ON CONFLICT(id) DO UPDATE SET display_name = excluded.display_name, address = excluded.address,
g1_member = 1, admin = MAX(identities.admin, excluded.admin)",
)
.bind(&voter)
.bind(found.name.clone().unwrap_or_else(|| voter.clone()))
.bind(&found.signer)
.bind(admin)
.bind(&now)
.execute(&mut *tx)
.await?;
let won = sqlx::query(
"UPDATE ballots SET status = 'confirmed', voter_id = ?, signer = ?, block = ?, event_index = ?,
event_id = ?, settled_at = ? WHERE id = ? AND status = 'pending'",
)
.bind(&voter)
.bind(&found.signer)
.bind(i64::from(position.block))
.bind(i64::from(position.event))
.bind(&found.event_id)
.bind(&now)
.bind(&row.id)
.execute(&mut *tx)
.await?;
if won.rows_affected() != 1 {
// Settled in the meantime: confirming twice changes nothing.
tx.rollback().await?;
return Ok(());
}
let lines: Vec<(String, i64, String)> =
sqlx::query_as("SELECT proposal_id, revision, choice FROM ballot_lines WHERE ballot_id = ?")
.bind(&row.id)
.fetch_all(&mut *tx)
.await?;
for (proposal_id, revision, choice) in lines {
let others: Vec<(i64, i64)> = sqlx::query_as(
"SELECT b.block, b.event_index FROM ballots b JOIN ballot_lines l ON l.ballot_id = b.id
WHERE b.status = 'confirmed' AND b.voter_id = ? AND l.proposal_id = ? AND b.id <> ?
AND b.block IS NOT NULL AND b.event_index IS NOT NULL",
)
.bind(&voter)
.bind(&proposal_id)
.bind(&row.id)
.fetch_all(&mut *tx)
.await?;
let others: Vec<ChainPosition> = others
.into_iter()
.filter_map(|(b, e)| {
Some(ChainPosition { block: u32::try_from(b).ok()?, event: u32::try_from(e).ok()? })
})
.collect();
if !ballot::decides(position, &others) {
continue;
}
match Choice::parse(&choice) {
Some(Choice::None) => {
sqlx::query("DELETE FROM votes WHERE proposal_id = ? AND identity_id = ?")
.bind(&proposal_id)
.bind(&voter)
.execute(&mut *tx)
.await?;
}
Some(c) => {
sqlx::query(
"INSERT INTO votes (proposal_id, identity_id, choice, revision, cast_at, source, block, event_index, ballot_id)
VALUES (?, ?, ?, ?, ?, 'chain', ?, ?, ?)
ON CONFLICT(proposal_id, identity_id) DO UPDATE SET choice = excluded.choice,
revision = excluded.revision, cast_at = excluded.cast_at, source = 'chain',
block = excluded.block, event_index = excluded.event_index, ballot_id = excluded.ballot_id",
)
.bind(&proposal_id)
.bind(&voter)
.bind(c.as_str())
.bind(revision)
.bind(&now)
.bind(i64::from(position.block))
.bind(i64::from(position.event))
.bind(&row.id)
.execute(&mut *tx)
.await?;
}
None => tracing::warn!(ballot = %row.id, %choice, "unknown choice in a ballot line"),
}
}
tx.commit().await?;
}
tracing::info!(ballot = %row.id, block = position.block, event = position.event, "ballot confirmed");
let entered: Vec<Entered> = adoption::run(state, &row.doc_id).await?;
sqlx::query("UPDATE ballots SET entered = ? WHERE id = ?")
.bind(serde_json::to_string(&entered).unwrap_or_else(|_| "[]".into()))
.bind(&row.id)
.execute(&state.db)
.await?;
Ok(())
}
/// Lets go of the pending ballots not seen on the chain in time.
pub async fn expire(state: &AppState) -> AppResult<u64> {
let now = util::now();
let done = sqlx::query(
"UPDATE ballots SET status = 'expired', settled_at = ? WHERE status = 'pending' AND expires_at <= ?",
)
.bind(&now)
.bind(&now)
.execute(&state.db)
.await?;
Ok(done.rows_affected())
}
/// One pass of the background task: expire, then read the indexer for each
/// pending ballot, oldest first. Returns how many were read.
pub async fn sweep(state: &AppState) -> AppResult<usize> {
expire(state).await?;
let rows = sqlx::query_as::<_, BallotRow>(&format!(
"{BALLOT_SELECT} WHERE status = 'pending' ORDER BY created_at, rowid LIMIT {PER_PASS}"
))
.fetch_all(&state.db)
.await?;
for row in &rows {
if let Err(e) = check(state, row).await {
tracing::warn!(ballot = %row.id, error = %e, "ballot check");
}
}
Ok(rows.len())
}
pub async fn watcher(state: AppState) {
let mut tick = tokio::time::interval(WATCH_EVERY);
loop {
tick.tick().await;
if let Err(e) = sweep(&state).await {
tracing::warn!(error = %e, "ballots");
}
}
}
// ---------------------------------------------------------------------------
// « Ton vote »
// ---------------------------------------------------------------------------
/// A vote of the person, on one version: hers, or cast by a ballot she asked for.
#[derive(Clone, Debug, sqlx::FromRow)]
pub struct MyVote {
pub proposal_id: String,
pub choice: String,
pub revision: i64,
pub source: String,
}
/// The votes `me` sees as « ton vote » in a document: those of its own identity,
/// and those a ballot it asked for cast on the chain (for the identity of the
/// wallet that signed). Only those: a session never sees what another session of
/// the same Ğ1 identity voted. The more recent on the chain comes last and wins.
pub async fn my_votes(state: &AppState, me: &str, doc_id: &str) -> AppResult<HashMap<String, MyVote>> {
let rows = sqlx::query_as::<_, MyVote>(
"SELECT v.proposal_id, v.choice, v.revision, v.source FROM votes v
JOIN proposals p ON p.id = v.proposal_id JOIN units u ON u.id = p.unit_id
WHERE u.doc_id = ?
AND (v.identity_id = ? OR v.ballot_id IN (SELECT id FROM ballots WHERE requested_by = ? AND status = 'confirmed'))
ORDER BY IFNULL(v.block, 0), IFNULL(v.event_index, 0), v.cast_at",
)
.bind(doc_id)
.bind(me)
.bind(me)
.fetch_all(&state.db)
.await?;
Ok(rows.into_iter().map(|v| (v.proposal_id.clone(), v)).collect())
}
/// What a ballot of the person, not yet counted, says of one version: pending on
/// the chain, or refused, or let go.
#[derive(Clone, Debug, serde::Serialize)]
#[serde(rename_all = "camelCase")]
pub struct MyBallot {
pub id: String,
pub status: String,
pub reason: Option<String>,
pub choice: String,
}
/// Per version: the most recent pending ballot of `me`; else its most recent
/// ballot if that one was refused or let go (a confirmed one shows as its vote).
/// A ballot the wallet never signed (dropped) says nothing.
pub async fn my_ballots(state: &AppState, me: &str, doc_id: &str) -> AppResult<HashMap<String, MyBallot>> {
#[derive(sqlx::FromRow)]
struct Row {
proposal_id: String,
id: String,
status: String,
reason: Option<String>,
choice: String,
}
let rows = sqlx::query_as::<_, Row>(
"SELECT l.proposal_id, b.id, b.status, b.reason, l.choice FROM ballots b
JOIN ballot_lines l ON l.ballot_id = b.id
WHERE b.doc_id = ? AND b.requested_by = ? AND IFNULL(b.reason, '') <> 'dropped'
ORDER BY b.created_at, b.rowid",
)
.bind(doc_id)
.bind(me)
.fetch_all(&state.db)
.await?;
let mut latest: HashMap<String, MyBallot> = HashMap::new();
let mut pending: HashMap<String, MyBallot> = HashMap::new();
for r in rows {
let b = MyBallot { id: r.id, status: r.status, reason: r.reason, choice: r.choice };
if b.status == "pending" {
pending.insert(r.proposal_id.clone(), b.clone());
}
latest.insert(r.proposal_id, b);
}
latest.retain(|_, b| b.status != "confirmed");
latest.extend(pending);
Ok(latest)
}
/// How this session votes in a group, and whether it may now.
pub async fn access(state: &AppState, user: &IdentityRow, group: &docs::GroupRow) -> AppResult<serde_json::Value> {
let g = group.to_group();
let granted = docs::granted(state, &g.slug, &user.id).await?;
let id = user.to_identity();
let way = trustin_access::vote_way(&g);
let (open, reason) = match way {
VoteWay::Server => {
let open = trustin_access::votes_in(&id, &g, granted);
(open, (!open).then_some("members_only"))
}
VoteWay::Chain => match (&state.cfg.vote, trustin_access::asks_ballot(&id, &g, granted)) {
(None, _) => (false, Some("chain_off")),
(Some(_), false) => (false, Some("members_only")),
(Some(_), true) => (true, None),
},
};
let chain = match (way, &state.cfg.vote) {
(VoteWay::Chain, Some(c)) => json!({
"account": c.account, "amount": c.amount, "nodes": c.nodes,
"test": c.test_node.is_some(), "testNode": c.test_node, "expiresHours": EXPIRES_HOURS,
}),
_ => serde_json::Value::Null,
};
Ok(json!({ "way": way, "open": open, "reason": reason, "chain": chain }))
}
+50 -1
View File
@@ -21,6 +21,39 @@ pub struct Publish {
pub author_email: String,
}
/// The vote on the Ğ1 chain (ADR 0008): where the 0.1 Ğ1 of each vote go, how
/// much, and the nodes the page sends the transaction to. Without
/// `LD_VOTE_ACCOUNT`, voting through the chain is off, and the interface says so.
#[derive(Clone, Debug)]
pub struct VoteChain {
/// The vote account, as a Ğ1 address (`g1…`), whatever form it was given in.
pub account: String,
pub account_key: [u8; 32],
/// Cents of Ğ1 per vote (`LD_VOTE_AMOUNT`, 10 = 0.1 Ğ1 by default).
pub amount: u64,
/// WebSocket endpoints of Ğ1 nodes, for the page (`LD_VOTE_NODES`).
pub nodes: Vec<String>,
/// Tests only, with the dev login: the page sends to this fake node instead
/// of building the transaction with polkadot-api (`LD_VOTE_TEST_NODE`).
pub test_node: Option<String>,
}
impl VoteChain {
pub const DEFAULT_AMOUNT: u64 = 10;
/// From the account in any SS58 form; `None` if it is not an address.
pub fn new(account: &str, amount: u64, nodes: Vec<String>, test_node: Option<String>) -> Option<Self> {
let (_, key) = ld_engine::address::decode(account.trim())?;
Some(Self {
account: ld_engine::address::encode(ld_engine::address::G1_PREFIX, &key),
account_key: key,
amount: amount.max(1),
nodes,
test_node,
})
}
}
#[derive(Clone, Debug)]
pub struct Config {
pub bind: String,
@@ -38,6 +71,8 @@ pub struct Config {
pub indexers: Vec<String>,
pub wot_fallback: u32,
pub build: String,
/// The vote on the chain; `None` = off (no `LD_VOTE_ACCOUNT`).
pub vote: Option<VoteChain>,
}
fn var(name: &str) -> Option<String> {
@@ -76,6 +111,18 @@ impl Config {
author_name: var("LD_PUBLISH_AUTHOR").unwrap_or_else(|| "libreDecision".into()),
author_email: var("LD_PUBLISH_EMAIL").unwrap_or_else(|| "decision@librodrome.org".into()),
});
let dev_login = flag("LD_DEV_LOGIN");
let vote = var("LD_VOTE_ACCOUNT").and_then(|account| {
let amount = var("LD_VOTE_AMOUNT").and_then(|v| v.parse().ok()).unwrap_or(VoteChain::DEFAULT_AMOUNT);
let nodes = list("LD_VOTE_NODES", &["wss://rpc.duniter.org", "wss://g1.axiom-team.fr/ws"]);
// A fake node for the page, never outside the local dev login.
let test_node = var("LD_VOTE_TEST_NODE").filter(|_| dev_login);
let chain = VoteChain::new(&account, amount, nodes, test_node);
if chain.is_none() {
tracing::error!("LD_VOTE_ACCOUNT is not a Ğ1 address: the vote through the chain stays off");
}
chain
});
Self {
bind: var("LD_BIND").unwrap_or_else(|| "0.0.0.0:8002".into()),
web_dir: PathBuf::from(var("LD_WEB_DIR").unwrap_or_else(|| "web/build".into())),
@@ -86,7 +133,7 @@ impl Config {
.to_owned(),
connect,
admin_g1_ids: list("LD_ADMIN_G1_IDS", &[]),
dev_login: flag("LD_DEV_LOGIN"),
dev_login,
bootstrap_admin: flag("LD_BOOTSTRAP_ADMIN"),
publish,
rpc_endpoints: list(
@@ -104,6 +151,7 @@ impl Config {
),
wot_fallback: var("LD_WOT_FALLBACK").and_then(|v| v.parse().ok()).unwrap_or(5745),
build: var("LD_BUILD").unwrap_or_else(|| "dev".into()),
vote,
data_dir,
}
}
@@ -134,6 +182,7 @@ impl Config {
indexers: vec![],
wot_fallback: 5745,
build: "test".into(),
vote: None,
}
}
}
+14 -22
View File
@@ -5,6 +5,7 @@
use crate::AppState;
use crate::auth::{IdentityRow, User};
use crate::ballots::{self, MyBallot, MyVote};
use crate::error::{AppError, AppResult};
use crate::participation;
use crate::util::{self, LangMap};
@@ -515,21 +516,15 @@ pub async fn translations_for(
Ok(out)
}
#[derive(sqlx::FromRow)]
struct MyVote {
proposal_id: String,
choice: String,
revision: i64,
}
fn proposal_json(
st: &DocState,
unit: &UnitRow,
p: &ProposalRow,
mine: Option<&MyVote>,
mine: (Option<&MyVote>, Option<&MyBallot>),
translations: Option<&BTreeMap<String, serde_json::Value>>,
me: &str,
) -> serde_json::Value {
let (mine, ballot) = mine;
let eval = st.evaluation(unit, p);
let (kind, target) = match p.kind() {
ProposalKind::Text => ("text", None),
@@ -554,6 +549,8 @@ fn proposal_json(
"signals": st.signal_counts.get(&p.id).copied().unwrap_or(0),
"myVote": mine.map(|v| v.choice.as_str()),
"myVoteStale": mine.is_some_and(|v| v.revision != p.revision),
"myVoteChain": mine.is_some_and(|v| v.source == "chain"),
"myBallot": ballot,
"translations": translations.cloned().unwrap_or_default(),
})
}
@@ -571,17 +568,8 @@ pub async fn document(
let visible: Vec<&ProposalRow> =
st.proposals.iter().filter(|p| p.status != "draft" || p.author_id.as_deref() == Some(&user.id)).collect();
let translations = translations_for(&state, &visible).await?;
let mine: HashMap<String, MyVote> = sqlx::query_as::<_, MyVote>(
"SELECT v.proposal_id, v.choice, v.revision FROM votes v JOIN proposals p ON p.id = v.proposal_id
JOIN units u ON u.id = p.unit_id WHERE u.doc_id = ? AND v.identity_id = ?",
)
.bind(&id)
.bind(&user.id)
.fetch_all(&state.db)
.await?
.into_iter()
.map(|v| (v.proposal_id.clone(), v))
.collect();
let mine = ballots::my_votes(&state, &user.id, &id).await?;
let pending = ballots::my_ballots(&state, &user.id, &id).await?;
let changes = inertia_changes(&state, &id).await?;
let units: Vec<_> = st
@@ -591,7 +579,10 @@ pub async fn document(
let of_unit: Vec<_> = visible
.iter()
.filter(|p| p.unit_id == u.id)
.map(|p| proposal_json(&st, u, p, mine.get(&p.id), translations.get(&p.id), &user.id))
.map(|p| {
let of = (mine.get(&p.id), pending.get(&p.id));
proposal_json(&st, u, p, of, translations.get(&p.id), &user.id)
})
.collect();
let in_force = u
.in_force_id
@@ -607,9 +598,10 @@ pub async fn document(
.collect();
let publication = publication_status(&state, &id).await?;
let can_vote = may_vote(&state, &user, &st.group).await?;
let vote = ballots::access(&state, &user, &st.group).await?;
Ok(Json(json!({
"canVote": can_vote,
"canVote": vote["open"],
"vote": vote,
"doc": {
"id": st.doc.id, "slug": st.doc.slug, "groupSlug": st.group.slug, "groupName": st.group.name,
"title": util::parse_lang(&st.doc.title), "summary": util::parse_lang(&st.doc.summary),
+223
View File
@@ -0,0 +1,223 @@
//! Reading the Ğ1 indexer (duniter-squid, GraphQL): the comments that carry a
//! given remark, with their author, the author's identity and the transfers of
//! the same transaction.
//!
//! A comment is found by `hash`, the blake2b-256 of the remark's bytes that the
//! chain publishes in its `System.Remarked` event. The indexers of
//! `LD_INDEXERS` are asked in turn: one that does not answer, or answers with
//! errors, is skipped; one that answers « nothing » may be behind, so the next
//! is asked too. Nobody answering is not a « no »: the ballot stays pending.
use crate::AppState;
use ld_engine::address;
use ld_engine::ballot::{ChainIdentity, ChainPosition, Comment, Transfer};
use serde::Deserialize;
use serde_json::json;
use std::time::Duration;
/// Validated on the indexer of the Ğ1 chain (indexer.duniter.org), 2026-10-30.
pub const BALLOT_QUERY: &str = "query Ballot($hash: String!) { txComments(condition: {hash: $hash}, \
orderBy: BLOCK_NUMBER_ASC, first: 20) { nodes { id blockNumber remark author { id identity { index name \
isMember } } transfersByCommentId { nodes { fromId toId amount } } } } }";
const TIMEOUT: Duration = Duration::from_secs(6);
#[derive(Deserialize)]
struct Answer {
data: Option<Data>,
#[serde(default)]
errors: Option<serde_json::Value>,
}
#[derive(Deserialize)]
#[serde(rename_all = "camelCase")]
struct Data {
tx_comments: Nodes<Node>,
}
#[derive(Deserialize)]
struct Nodes<T> {
nodes: Vec<T>,
}
#[derive(Deserialize)]
#[serde(rename_all = "camelCase")]
struct Node {
id: String,
block_number: u32,
remark: String,
author: Author,
transfers_by_comment_id: Nodes<TransferNode>,
}
#[derive(Deserialize)]
struct Author {
id: String,
identity: Option<IdentityNode>,
}
#[derive(Deserialize)]
#[serde(rename_all = "camelCase")]
struct IdentityNode {
index: u32,
name: String,
is_member: bool,
}
#[derive(Deserialize)]
#[serde(rename_all = "camelCase")]
struct TransferNode {
from_id: String,
to_id: String,
/// A big integer, served as a string.
amount: serde_json::Value,
}
/// A comment of the chain, as the engine judges it, with what the server keeps.
#[derive(Clone, Debug)]
pub struct Found {
pub comment: Comment,
pub remark: String,
pub event_id: String,
/// The account that signed, as a Ğ1 address.
pub signer: String,
/// The name of the signer's identity, if any.
pub name: Option<String>,
}
#[derive(Debug)]
pub enum Reading {
/// No indexer answered.
Silent,
/// The comments found (possibly none).
Found(Vec<Found>),
}
fn amount(v: &serde_json::Value) -> Option<u64> {
match v {
serde_json::Value::String(s) => s.parse().ok(),
serde_json::Value::Number(n) => n.as_u64(),
_ => None,
}
}
fn found(node: Node) -> Option<Found> {
let position = ChainPosition::from_event_id(&node.id)?;
if position.block != node.block_number {
return None;
}
let (_, author) = address::decode(&node.author.id)?;
let transfers = node
.transfers_by_comment_id
.nodes
.iter()
.map(|t| {
Some(Transfer {
from: address::decode(&t.from_id)?.1,
to: address::decode(&t.to_id)?.1,
amount: amount(&t.amount)?,
})
})
.collect::<Option<Vec<_>>>()?;
Some(Found {
comment: Comment {
position,
author,
identity: node.author.identity.as_ref().map(|i| ChainIdentity { index: i.index, member: i.is_member }),
transfers,
},
remark: node.remark,
event_id: node.id,
signer: address::encode(address::G1_PREFIX, &author),
name: node.author.identity.map(|i| i.name),
})
}
/// The comments of one answer of the indexer; `None` if it is not one.
pub fn parse(body: &serde_json::Value) -> Option<Vec<Found>> {
let answer: Answer = serde_json::from_value(body.clone()).ok()?;
if answer.errors.as_ref().is_some_and(|e| !e.is_null()) {
return None;
}
let nodes = answer.data?.tx_comments.nodes;
let count = nodes.len();
let out: Vec<Found> = nodes.into_iter().filter_map(found).collect();
if out.len() != count {
tracing::warn!(read = out.len(), served = count, "indexer: comments that do not read were left aside");
}
Some(out)
}
/// The comments carrying the remark of hash `remark_hash` (`0x…`).
pub async fn comments(state: &AppState, remark_hash: &str) -> Reading {
let body = json!({ "query": BALLOT_QUERY, "variables": { "hash": remark_hash } });
let mut answered = false;
for url in &state.cfg.indexers {
let res = state.http.post(url).timeout(TIMEOUT).json(&body).send().await;
let value: Option<serde_json::Value> = match res {
Ok(r) if r.status().is_success() => r.json().await.ok(),
Ok(r) => {
tracing::debug!(%url, status = %r.status(), "indexer");
None
}
Err(e) => {
tracing::debug!(%url, error = %e, "indexer");
None
}
};
match value.as_ref().and_then(parse) {
Some(list) if !list.is_empty() => return Reading::Found(list),
Some(_) => answered = true,
None => {}
}
}
if answered { Reading::Found(Vec::new()) } else { Reading::Silent }
}
#[cfg(test)]
mod tests {
use super::*;
/// A real answer of the Ğ1 indexer (2026-10-30), for the remark « Merci ».
const REAL: &str = include_str!("../tests/fixtures/indexer-merci.json");
#[test]
fn a_real_answer_reads() {
let body: serde_json::Value = serde_json::from_str(REAL).expect("json");
let list = parse(&body).expect("an answer");
assert_eq!(list.len(), 4);
let first = &list[0];
assert_eq!(first.comment.position, ChainPosition { block: 196_944, event: 3 });
assert_eq!(first.remark, "Merci ");
assert_eq!(first.signer, "g1NrqKgJW9FoFabaYW82zhS9tDWdQcL1DGpivaJdj7GW9M6iv");
assert_eq!(first.name.as_deref(), Some("SIMONEANTONELLI"));
assert_eq!(first.comment.identity, Some(ChainIdentity { index: 1304, member: true }));
assert_eq!(first.comment.transfers.len(), 1);
assert_eq!(first.comment.transfers[0].amount, 30_000);
assert_eq!(first.comment.transfers[0].from, first.comment.author);
// An account without identity.
assert_eq!(list[2].comment.identity, None);
assert_eq!(list[2].name, None);
}
#[test]
fn errors_or_a_strange_shape_are_no_answer() {
assert!(parse(&json!({ "errors": [{ "message": "boom" }] })).is_none());
assert!(parse(&json!({ "data": null })).is_none());
assert!(parse(&json!({ "nope": 1 })).is_none());
assert_eq!(parse(&json!({ "data": { "txComments": { "nodes": [] } } })).map(|l| l.len()), Some(0));
}
#[test]
fn a_node_that_does_not_read_is_left_aside() {
let body = json!({ "data": { "txComments": { "nodes": [
{ "id": "0000000010-abcde-000002", "blockNumber": 11, "remark": "x",
"author": { "id": "g1NrqKgJW9FoFabaYW82zhS9tDWdQcL1DGpivaJdj7GW9M6iv", "identity": null },
"transfersByCommentId": { "nodes": [] } },
{ "id": "0000000010-abcde-000002", "blockNumber": 10, "remark": "x",
"author": { "id": "not an address", "identity": null },
"transfersByCommentId": { "nodes": [] } },
] } } });
assert_eq!(parse(&body).map(|l| l.len()), Some(0), "block and id disagree; a bad address");
}
}
+9 -2
View File
@@ -1,16 +1,19 @@
//! libreDecision server.
//!
//! One binary serves the JSON API under `/api`, the compiled Svelte interface,
//! and runs two background workers: the electorate refresher (size of the Ğ1
//! web of trust) and the publisher (adopted texts pushed to the git repository
//! and runs four background workers: the electorate refresher (size of the Ğ1
//! web of trust), the adoption sweeper, the ballot watcher (votes signed on the
//! Ğ1 chain, confirmed on the indexer) and the publisher (adopted texts pushed to the git repository
//! that client applications read).
pub mod access;
pub mod adoption;
pub mod auth;
pub mod ballots;
pub mod config;
pub mod docs;
pub mod error;
pub mod indexer;
pub mod inertia_amend;
pub mod overview;
pub mod participation;
@@ -116,6 +119,9 @@ pub fn api_router() -> Router<AppState> {
.route("/docs/{id}/preview", get(docs::preview))
.route("/docs/{id}/assist", post(proposals::assist))
.route("/docs/{id}/proposals", post(proposals::create))
.route("/docs/{id}/votes", post(ballots::vote_many))
.route("/docs/{id}/ballots", post(ballots::create))
.route("/ballots/{id}", get(ballots::status).delete(ballots::drop))
.route("/proposals/{id}", get(proposals::detail).patch(proposals::edit))
.route("/proposals/{id}/submit", post(proposals::submit))
.route("/proposals/{id}/withdraw", post(proposals::withdraw))
@@ -211,6 +217,7 @@ pub async fn run() -> anyhow::Result<()> {
tokio::spawn(wot::refresher(state.clone()));
tokio::spawn(publish::worker(state.clone()));
tokio::spawn(adoption::sweeper(state.clone()));
tokio::spawn(ballots::watcher(state.clone()));
let listener = tokio::net::TcpListener::bind(&state.cfg.bind).await?;
tracing::info!(bind = %state.cfg.bind, build = %state.cfg.build, "libreDecision listening");
+5 -3
View File
@@ -109,7 +109,7 @@ pub async fn decisions(
) -> AppResult<Json<Value>> {
let group = docs::load_group(&state, &slug).await?;
docs::require_open(&state, &user, &group).await?;
let can_vote = docs::may_vote(&state, &user, &group).await?;
let can_vote = crate::ballots::access(&state, &user, &group).await?["open"].as_bool().unwrap_or(false);
#[derive(sqlx::FromRow)]
struct DocRow {
id: String,
@@ -149,9 +149,11 @@ async fn modules(state: &AppState, doc_id: &str, me: &str) -> AppResult<Vec<Modu
IFNULL(p.id = u.in_force_id, 0) AS in_force,
(p.entered_at IS NOT NULL OR p.confirmed_at IS NOT NULL OR p.origin <> 'seed') AS adopted,
EXISTS (SELECT 1 FROM votes v
WHERE v.proposal_id = p.id AND v.identity_id = ? AND v.revision = p.revision) AS voted
WHERE v.proposal_id = p.id AND v.revision = p.revision
AND (v.identity_id = ?1 OR v.ballot_id IN
(SELECT b.id FROM ballots b WHERE b.requested_by = ?1 AND b.status = 'confirmed'))) AS voted
FROM units u JOIN proposals p ON p.unit_id = u.id
WHERE u.doc_id = ? AND p.kind <> 'inertia' AND (p.id = u.in_force_id OR p.status = 'open')
WHERE u.doc_id = ?2 AND p.kind <> 'inertia' AND (p.id = u.in_force_id OR p.status = 'open')
ORDER BY u.position, u.id",
)
.bind(me)
+21 -33
View File
@@ -14,12 +14,14 @@
use crate::AppState;
use crate::adoption;
use crate::auth::{IdentityRow, User};
use crate::ballots;
use crate::docs::{self, DocState, PROPOSAL_SELECT, ProposalRow, UnitRow};
use crate::error::{AppError, AppResult};
use crate::util;
use axum::Json;
use axum::extract::{Path, State};
use ld_engine::assist::{self, Hint};
use ld_engine::ballot::Choice;
use ld_engine::edit::{self, EditKind};
use ld_engine::inertia::InertiaLevel;
use serde::Deserialize;
@@ -616,12 +618,8 @@ pub async fn detail(
}));
}
let my_vote: Option<(String, i64)> =
sqlx::query_as("SELECT choice, revision FROM votes WHERE proposal_id = ? AND identity_id = ?")
.bind(&id)
.bind(&user.id)
.fetch_optional(&state.db)
.await?;
let my_vote = ballots::my_votes(&state, &user.id, &st.doc.id).await?.remove(&id);
let my_ballot = ballots::my_ballots(&state, &user.id, &st.doc.id).await?.remove(&id);
let diff = match (&in_force, p.kind.as_str()) {
(Some(v), "text") => Some(edit::diff(&v.body, &p.body)),
_ => None,
@@ -640,7 +638,9 @@ pub async fn detail(
"seedMinimal": p.status == "in_force" && p.entered_at.is_none() && p.confirmed_at.is_none() && p.origin == "seed",
"tally": st.tally(&p.id), "evaluation": eval, "votedAtInertia": voted_at,
"blocked": st.blocked.contains(&p.id),
"myVote": my_vote.as_ref().map(|v| &v.0), "myVoteStale": my_vote.as_ref().is_some_and(|v| v.1 != p.revision),
"myVote": my_vote.as_ref().map(|v| &v.choice),
"myVoteStale": my_vote.as_ref().is_some_and(|v| v.revision != p.revision),
"myVoteChain": my_vote.as_ref().is_some_and(|v| v.source == "chain"), "myBallot": my_ballot,
"translations": tr.get(&p.id).cloned().unwrap_or_default(),
},
"unit": { "id": unit.id, "code": unit.code, "inertia": docs::inertia_of(unit) },
@@ -666,6 +666,8 @@ pub struct VoteBody {
choice: Option<String>,
}
/// One vote on the server, in a managed group. Where the vote goes through the
/// chain (Monnaie libre), it is a ballot (`ballots::create`), never this.
pub async fn vote(
State(state): State<AppState>,
User(user): User,
@@ -673,37 +675,23 @@ pub async fn vote(
Json(b): Json<VoteBody>,
) -> AppResult<Json<serde_json::Value>> {
let (p, st) = guarded(&state, &user, &id).await?;
if trustin_access::vote_way(&st.group.to_group()) == trustin_access::VoteWay::Chain {
return Err(AppError::Conflict("chain_only"));
}
if !matches!(p.status.as_str(), "open" | "in_force") {
return Err(AppError::Conflict("not_votable"));
}
// Withdrawing one's vote is always possible; casting one follows trustinWallet.
if b.choice.is_some() && !docs::may_vote(&state, &user, &st.group).await? {
return Err(AppError::Conflict("wot_members_only"));
if !docs::may_vote(&state, &user, &st.group).await? {
return Err(AppError::Conflict("members_only"));
}
match b.choice.as_deref() {
Some(choice @ ("for" | "against")) => {
sqlx::query(
"INSERT INTO votes (proposal_id, identity_id, choice, revision, cast_at) VALUES (?, ?, ?, ?, ?)
ON CONFLICT(proposal_id, identity_id) DO UPDATE SET choice = excluded.choice,
revision = excluded.revision, cast_at = excluded.cast_at",
)
.bind(&id)
.bind(&user.id)
.bind(choice)
.bind(p.revision)
.bind(util::now())
.execute(&state.db)
.await?;
}
None => {
sqlx::query("DELETE FROM votes WHERE proposal_id = ? AND identity_id = ?")
.bind(&id)
.bind(&user.id)
.execute(&state.db)
.await?;
}
let choice = match b.choice.as_deref() {
Some("for") => Choice::For,
Some("against") => Choice::Against,
None => Choice::None,
Some(_) => return Err(AppError::Invalid("choice")),
}
};
let cast = ballots::Cast { proposal_id: p.id.clone(), revision: p.revision, choice };
ballots::write_server_votes(&state, &user.id, &[cast]).await?;
let entered = adoption::run(&state, &st.doc.id).await?;
Ok(Json(json!({ "entered": entered })))
}