forked from yvv/decision
v3 : vote par la chaîne, corrections de la relecture de 74f96be (serveur)
- adresse : refusée au-delà de 64 caractères avant tout décodage (le base58
coûte le carré de la longueur : 80 000 caractères prenaient 38 s) ;
- tâche de fond : 50 bulletins par passe, le moins récemment lu d'abord ;
lu une dernière fois à son échéance, une heure de grâce si l'indexeur se tait ;
- plafond de 20 par identité : en attente, plus lâchés depuis une heure ;
purge des lâchés après un jour, des expirés après une semaine ;
- bulletin Ğecko : seul un commentaire payé le tranche, une copie nue s'ignore ;
- finalité : seul un bloc ≤ squidStatus.height compte ; recherche filtrée
(auteur lié, ou virement vers le compte des votes) et paginée par 50 ;
- « ton vote » : un bulletin en attente plus ancien qu'un vote confirmé se tait ;
- reçu (GET /api/ballots/{id}/receipt) et historique (GET /api/ballots),
pour qui l'a demandé seulement ;
- un nom vu seulement par un vote sur la chaîne reste libre pour un lien d'équipe ;
- totaux affichés isolés (shown_tally) pour une publication par paliers.
Les sondes de la relecture deviennent des tests, vus rouges avant le correctif.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
+272
-39
@@ -15,9 +15,12 @@
|
||||
//! - **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.
|
||||
//! A background task reads the indexer for pending ballots every 10 seconds,
|
||||
//! at most 50 a pass, the least recently read first: every pending ballot is
|
||||
//! read at least every ⌈n / 50⌉ passes, whether a page waits for it or not (a
|
||||
//! Ğecko code). A ballot is read one last time at its end (24 hours), then let
|
||||
//! go; the adoption pass runs after each confirmation. Ballots let go are purged
|
||||
//! after a while (a day for those the wallet never signed, a week for the others).
|
||||
|
||||
use crate::AppState;
|
||||
use crate::adoption::{self, Entered};
|
||||
@@ -28,6 +31,8 @@ use crate::indexer::{self, Found, Reading};
|
||||
use crate::util;
|
||||
use axum::Json;
|
||||
use axum::extract::{Path, State};
|
||||
use axum::http::{HeaderValue, header};
|
||||
use axum::response::{IntoResponse, Response};
|
||||
use ld_engine::address;
|
||||
use ld_engine::ballot::{self, ChainPosition, Choice, Expected, Verdict};
|
||||
use rand::RngCore;
|
||||
@@ -40,15 +45,26 @@ 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.
|
||||
/// Ballots one identity may hold at once: those pending, and those let go within
|
||||
/// the last hour (`DELETE`, the wallet did not sign) — whatever the session.
|
||||
const MAX_PENDING: i64 = 20;
|
||||
const DROPPED_WINDOW_SECS: i64 = 3600;
|
||||
/// A ballot not seen on the chain within this time is let go.
|
||||
pub const EXPIRES_HOURS: i64 = 24;
|
||||
/// At its end, a ballot no indexer could be read for — or whose comment waits
|
||||
/// for a final block — is kept and read again, this long at most.
|
||||
const GRACE_SECS: i64 = 3600;
|
||||
/// Let go, a ballot is purged, lines and all: a day after the wallet did not sign
|
||||
/// it, a week after it was not seen in time (under the article, it says so meanwhile).
|
||||
const PURGE_DROPPED_SECS: i64 = 24 * 3600;
|
||||
const PURGE_EXPIRED_SECS: i64 = 7 * 24 * 3600;
|
||||
/// 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.
|
||||
/// Pending ballots read per pass of the background task, the least recently read first.
|
||||
const PER_PASS: i64 = 50;
|
||||
/// Ballots a history shows, the most recent first.
|
||||
const HISTORY: i64 = 200;
|
||||
|
||||
/// 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.
|
||||
@@ -226,14 +242,16 @@ pub async fn create(
|
||||
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 > ?",
|
||||
// Pending, or let go within the hour: letting a ballot go does not free its place at once.
|
||||
let held: i64 = sqlx::query_scalar(
|
||||
"SELECT COUNT(*) FROM ballots WHERE requested_by = ?
|
||||
AND (status = 'pending' OR (reason = 'dropped' AND settled_at > ?))",
|
||||
)
|
||||
.bind(&user.id)
|
||||
.bind(util::now())
|
||||
.bind(util::in_seconds(-DROPPED_WINDOW_SECS))
|
||||
.fetch_one(&state.db)
|
||||
.await?;
|
||||
if pending >= MAX_PENDING {
|
||||
if held >= MAX_PENDING {
|
||||
return Err(AppError::Conflict("too_many_ballots"));
|
||||
}
|
||||
let lines: Vec<ballot::Line> = casts
|
||||
@@ -333,6 +351,160 @@ pub async fn drop(
|
||||
Ok(Json(json!({ "ballot": view(&state, &row).await? })))
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// The receipt: the ballot itself, for the one who asked — never anyone else
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/// A title as stored (`{lang: text}`), as JSON; a bare text if it is not a map.
|
||||
fn title_json(raw: &str) -> serde_json::Value {
|
||||
serde_json::from_str::<serde_json::Value>(raw).ok().filter(|v| v.is_object()).unwrap_or_else(|| json!(raw))
|
||||
}
|
||||
|
||||
/// `GET /api/ballots` — the history of the person's ballots, the most recent
|
||||
/// first: those of its identity only, and not those the wallet never signed.
|
||||
pub async fn mine(State(state): State<AppState>, User(user): User) -> AppResult<Json<serde_json::Value>> {
|
||||
#[derive(sqlx::FromRow)]
|
||||
struct Row {
|
||||
id: String,
|
||||
doc_id: String,
|
||||
group_slug: String,
|
||||
slug: String,
|
||||
title: String,
|
||||
digest: String,
|
||||
status: String,
|
||||
reason: Option<String>,
|
||||
block: Option<i64>,
|
||||
voter: Option<String>,
|
||||
lines: i64,
|
||||
created_at: String,
|
||||
settled_at: Option<String>,
|
||||
}
|
||||
let rows = sqlx::query_as::<_, Row>(&format!(
|
||||
"SELECT b.id, b.doc_id, d.group_slug, d.slug, d.title, b.digest, b.status, b.reason, b.block,
|
||||
i.display_name AS voter, (SELECT COUNT(*) FROM ballot_lines l WHERE l.ballot_id = b.id) AS lines,
|
||||
b.created_at, b.settled_at
|
||||
FROM ballots b JOIN documents d ON d.id = b.doc_id LEFT JOIN identities i ON i.id = b.voter_id
|
||||
WHERE b.requested_by = ? AND IFNULL(b.reason, '') <> 'dropped'
|
||||
ORDER BY b.created_at DESC, b.rowid DESC LIMIT {HISTORY}"
|
||||
))
|
||||
.bind(&user.id)
|
||||
.fetch_all(&state.db)
|
||||
.await?;
|
||||
let list: Vec<serde_json::Value> = rows
|
||||
.into_iter()
|
||||
.map(|r| {
|
||||
json!({
|
||||
"id": r.id, "doc": { "id": r.doc_id, "group": r.group_slug, "slug": r.slug, "title": title_json(&r.title) },
|
||||
"comment": format!("{}{}", ballot::PREFIX, r.digest), "status": r.status, "reason": r.reason,
|
||||
"block": r.block, "voter": r.voter, "lines": r.lines, "createdAt": r.created_at, "settledAt": r.settled_at,
|
||||
})
|
||||
})
|
||||
.collect();
|
||||
Ok(Json(json!({ "ballots": list })))
|
||||
}
|
||||
|
||||
/// `GET /api/ballots/{id}/receipt` — the ballot itself, to download: its lines,
|
||||
/// its salt, the canonical text they make, its seal and where it stands. With
|
||||
/// it, the person recomputes the seal and finds it on the chain; nobody else
|
||||
/// ever gets it (the chain shows who voted, never what).
|
||||
pub async fn receipt(State(state): State<AppState>, User(user): User, Path(id): Path<String>) -> AppResult<Response> {
|
||||
#[derive(sqlx::FromRow)]
|
||||
struct Extra {
|
||||
salt: String,
|
||||
signer: Option<String>,
|
||||
event_id: Option<String>,
|
||||
settled_at: Option<String>,
|
||||
title: String,
|
||||
}
|
||||
#[derive(sqlx::FromRow)]
|
||||
struct LineRow {
|
||||
proposal_id: String,
|
||||
revision: i64,
|
||||
choice: String,
|
||||
code: String,
|
||||
title: Option<String>,
|
||||
}
|
||||
let row = load(&state, &id).await?;
|
||||
if row.requested_by != user.id {
|
||||
return Err(AppError::NotFound);
|
||||
}
|
||||
let extra = sqlx::query_as::<_, Extra>(
|
||||
"SELECT b.salt, b.signer, b.event_id, b.settled_at, d.title FROM ballots b JOIN documents d ON d.id = b.doc_id
|
||||
WHERE b.id = ?",
|
||||
)
|
||||
.bind(&id)
|
||||
.fetch_one(&state.db)
|
||||
.await?;
|
||||
let lines = sqlx::query_as::<_, LineRow>(
|
||||
"SELECT l.proposal_id, l.revision, l.choice, u.code, p.title FROM ballot_lines l
|
||||
JOIN proposals p ON p.id = l.proposal_id JOIN units u ON u.id = p.unit_id
|
||||
WHERE l.ballot_id = ? ORDER BY l.proposal_id",
|
||||
)
|
||||
.bind(&id)
|
||||
.fetch_all(&state.db)
|
||||
.await?;
|
||||
let sealed: Vec<ballot::Line> = lines
|
||||
.iter()
|
||||
.filter_map(|l| {
|
||||
Some(ballot::Line {
|
||||
proposal_id: l.proposal_id.clone(),
|
||||
revision: u32::try_from(l.revision).ok()?,
|
||||
choice: Choice::parse(&l.choice)?,
|
||||
})
|
||||
})
|
||||
.collect();
|
||||
let canonical = ballot::canonical(&row.doc_id, row.author.as_deref(), &sealed, &extra.salt)
|
||||
.map_err(|e| anyhow::anyhow!("ballot {id} does not read back: {e:?}"))?;
|
||||
let seal = ballot::seal(&row.doc_id, row.author.as_deref(), &sealed, &extra.salt)
|
||||
.map_err(|e| anyhow::anyhow!("ballot {id} does not read back: {e:?}"))?;
|
||||
if seal.digest != row.digest || sealed.len() != lines.len() {
|
||||
return Err(anyhow::anyhow!("ballot {id}: its lines no longer make its seal").into());
|
||||
}
|
||||
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 body = json!({
|
||||
"format": "ld1",
|
||||
"id": row.id,
|
||||
"doc": row.doc_id,
|
||||
"docTitle": title_json(&extra.title),
|
||||
"author": row.author,
|
||||
"lines": lines.iter().map(|l| json!({
|
||||
"proposalId": l.proposal_id, "revision": l.revision, "choice": l.choice, "article": l.code, "title": l.title,
|
||||
})).collect::<Vec<_>>(),
|
||||
"salt": extra.salt,
|
||||
"canonical": canonical,
|
||||
"digest": seal.digest,
|
||||
"comment": seal.comment,
|
||||
"remarkHash": seal.remark_hash,
|
||||
"account": row.account,
|
||||
"amount": row.amount,
|
||||
"status": row.status,
|
||||
"reason": row.reason,
|
||||
"block": row.block,
|
||||
"event": extra.event_id,
|
||||
"signer": extra.signer,
|
||||
"voter": voter,
|
||||
"createdAt": row.created_at,
|
||||
"settledAt": extra.settled_at,
|
||||
});
|
||||
let day = row.created_at.get(..10).unwrap_or("vote");
|
||||
let name = format!("recu-vote-{day}-{}.json", row.id.get(..8).unwrap_or(&row.id));
|
||||
let mut res = Json(body).into_response();
|
||||
let headers = res.headers_mut();
|
||||
if let Ok(v) = HeaderValue::from_str(&format!("attachment; filename=\"{name}\"")) {
|
||||
headers.insert(header::CONTENT_DISPOSITION, v);
|
||||
}
|
||||
headers.insert(header::CACHE_CONTROL, HeaderValue::from_static("no-store"));
|
||||
Ok(res)
|
||||
}
|
||||
|
||||
async fn view(state: &AppState, row: &BallotRow) -> AppResult<serde_json::Value> {
|
||||
let voter: Option<String> = match &row.voter_id {
|
||||
Some(v) => {
|
||||
@@ -352,29 +524,47 @@ async fn view(state: &AppState, row: &BallotRow) -> AppResult<serde_json::Value>
|
||||
}))
|
||||
}
|
||||
|
||||
/// Reads the indexer for one pending ballot and settles it if the chain has spoken.
|
||||
/// Reads the indexer for one pending ballot and settles it if the chain has spoken
|
||||
/// — in a final block. At its end, a ballot is read one last time, then let go.
|
||||
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 due = row.expires_at <= now;
|
||||
let past_grace = row.expires_at <= util::in_seconds(-GRACE_SECS);
|
||||
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(()) };
|
||||
let comment = row.comment();
|
||||
// Only the comments that carry exactly the remark of the ballot.
|
||||
let judged = |list: &[Found]| {
|
||||
let mine: Vec<ballot::Comment> =
|
||||
list.iter().filter(|f| f.remark == comment).map(|f| f.comment.clone()).collect();
|
||||
ballot::judge(&mine, &expected)
|
||||
};
|
||||
let filter = indexer::filter(&row.account, amount, row.author.as_deref());
|
||||
let reading = indexer::comments(state, &row.remark_hash, &filter, |list| judged(list).is_some()).await;
|
||||
sqlx::query("UPDATE ballots SET checked_at = ? WHERE id = ?").bind(&now).bind(&row.id).execute(&state.db).await?;
|
||||
let (list, unfinal) = match reading {
|
||||
Reading::Found { comments, unfinal } => (comments, unfinal),
|
||||
Reading::Silent => {
|
||||
tracing::debug!(ballot = %row.id, "no indexer answered: the ballot stays pending");
|
||||
if past_grace {
|
||||
let_go(state, row).await?;
|
||||
}
|
||||
return Ok(());
|
||||
}
|
||||
};
|
||||
let Some((position, verdict)) = judged(&list) else {
|
||||
// Nothing final settles it. At its end: let go, unless a comment waits for its block to be final.
|
||||
if due && (!unfinal || past_grace) {
|
||||
let_go(state, row).await?;
|
||||
}
|
||||
return Ok(());
|
||||
};
|
||||
let Some(found) = list.iter().find(|f| f.comment.position == position && f.remark == comment) 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) => {
|
||||
@@ -516,25 +706,59 @@ async fn confirm(state: &AppState, row: &BallotRow, found: &Found, index: u32) -
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Lets go of the pending ballots not seen on the chain in time.
|
||||
/// Lets go of one pending ballot not seen on the chain in time.
|
||||
async fn let_go(state: &AppState, row: &BallotRow) -> AppResult<()> {
|
||||
sqlx::query("UPDATE ballots SET status = 'expired', settled_at = ? WHERE id = ? AND status = 'pending'")
|
||||
.bind(util::now())
|
||||
.bind(&row.id)
|
||||
.execute(&state.db)
|
||||
.await?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Lets go of the pending ballots past their end and the grace after it: those
|
||||
/// the indexer could not be read for 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)
|
||||
.bind(util::now())
|
||||
.bind(util::in_seconds(-GRACE_SECS))
|
||||
.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.
|
||||
/// Purges the ballots let go long enough ago, lines and all. A ballot let go
|
||||
/// never counted: nothing of a vote is lost.
|
||||
pub async fn purge(state: &AppState) -> AppResult<u64> {
|
||||
const WHICH: &str = "SELECT id FROM ballots WHERE status = 'expired' AND settled_at IS NOT NULL
|
||||
AND ((reason = 'dropped' AND settled_at < ?) OR settled_at < ?)";
|
||||
let (dropped, expired) = (util::in_seconds(-PURGE_DROPPED_SECS), util::in_seconds(-PURGE_EXPIRED_SECS));
|
||||
let mut tx = state.db.begin().await?;
|
||||
sqlx::query(&format!("DELETE FROM ballot_lines WHERE ballot_id IN ({WHICH})"))
|
||||
.bind(&dropped)
|
||||
.bind(&expired)
|
||||
.execute(&mut *tx)
|
||||
.await?;
|
||||
let done = sqlx::query(&format!("DELETE FROM ballots WHERE id IN ({WHICH})"))
|
||||
.bind(&dropped)
|
||||
.bind(&expired)
|
||||
.execute(&mut *tx)
|
||||
.await?;
|
||||
tx.commit().await?;
|
||||
Ok(done.rows_affected())
|
||||
}
|
||||
|
||||
/// One pass of the background task: let go and purge, then read the indexer for
|
||||
/// the pending ballots, the least recently read first (never read, then the
|
||||
/// oldest reading). Returns how many were read.
|
||||
pub async fn sweep(state: &AppState) -> AppResult<usize> {
|
||||
expire(state).await?;
|
||||
purge(state).await?;
|
||||
let rows = sqlx::query_as::<_, BallotRow>(&format!(
|
||||
"{BALLOT_SELECT} WHERE status = 'pending' ORDER BY created_at, rowid LIMIT {PER_PASS}"
|
||||
"{BALLOT_SELECT} WHERE status = 'pending'
|
||||
ORDER BY checked_at IS NOT NULL, checked_at, created_at, rowid LIMIT {PER_PASS}"
|
||||
))
|
||||
.fetch_all(&state.db)
|
||||
.await?;
|
||||
@@ -600,9 +824,10 @@ pub struct MyBallot {
|
||||
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.
|
||||
/// Per version: the most recent pending ballot of `me` asked after its last
|
||||
/// confirmed one; 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; one asked before a confirmed vote says nothing either.
|
||||
pub async fn my_ballots(state: &AppState, me: &str, doc_id: &str) -> AppResult<HashMap<String, MyBallot>> {
|
||||
#[derive(sqlx::FromRow)]
|
||||
struct Row {
|
||||
@@ -626,12 +851,20 @@ pub async fn my_ballots(state: &AppState, me: &str, doc_id: &str) -> AppResult<H
|
||||
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());
|
||||
match b.status.as_str() {
|
||||
// A confirmed vote: what was asked before it says nothing more.
|
||||
"confirmed" => {
|
||||
latest.remove(&r.proposal_id);
|
||||
pending.remove(&r.proposal_id);
|
||||
}
|
||||
"pending" => {
|
||||
pending.insert(r.proposal_id, b);
|
||||
}
|
||||
_ => {
|
||||
latest.insert(r.proposal_id, b);
|
||||
}
|
||||
}
|
||||
latest.insert(r.proposal_id, b);
|
||||
}
|
||||
latest.retain(|_, b| b.status != "confirmed");
|
||||
latest.extend(pending);
|
||||
Ok(latest)
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user