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:
Yvv
2026-10-30 20:52:47 +01:00
co-authored by Claude Opus 5.5
parent a576b68570
commit 3541e1b1a6
19 changed files with 2595 additions and 105 deletions
+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 })))
}