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:
Yvv
2026-10-31 02:14:54 +01:00
co-authored by Claude Opus 5.5
parent 1a35d0f521
commit e63e2ec283
10 changed files with 1552 additions and 103 deletions
+272 -39
View File
@@ -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)
}
+16 -2
View File
@@ -396,6 +396,20 @@ impl DocState {
self.tallies.get(id).copied().unwrap_or_default()
}
/// The totals shown to people: today the live count, as `tally`. The one
/// place where a publication by steps would plug in — on the chain, who
/// voted and when is public, and live totals tell what (the secret of the
/// vote, review of 74f96be, a decision that waits for Yvv). Adoption reads
/// `tally`; the published texts (`publish.rs`) would follow this one.
pub fn shown_tally(&self, id: &str) -> Tally {
self.tally(id)
}
/// How far the threshold is, as shown: on `shown_tally`.
pub fn shown_evaluation(&self, unit: &UnitRow, p: &ProposalRow) -> Option<Evaluation> {
self.scale().evaluate(self.shown_tally(&p.id), self.voting_level(unit, p)).ok()
}
pub fn unit_states(&self) -> Vec<UnitState> {
let by_id: HashMap<&str, &ProposalRow> = self.proposals.iter().map(|p| (p.id.as_str(), p)).collect();
self.units
@@ -525,7 +539,7 @@ fn proposal_json(
me: &str,
) -> serde_json::Value {
let (mine, ballot) = mine;
let eval = st.evaluation(unit, p);
let eval = st.shown_evaluation(unit, p);
let (kind, target) = match p.kind() {
ProposalKind::Text => ("text", None),
ProposalKind::Remove => ("remove", None),
@@ -542,7 +556,7 @@ fn proposal_json(
"revision": p.revision, "createdAt": p.created_at, "updatedAt": p.updated_at,
"submittedAt": p.submitted_at, "enteredAt": p.entered_at, "confirmedAt": p.confirmed_at,
"seedMinimal": p.status == "in_force" && p.entered_at.is_none() && p.confirmed_at.is_none() && p.origin == "seed",
"tally": st.tally(&p.id),
"tally": st.shown_tally(&p.id),
"evaluation": eval,
"votedAtInertia": voted_at,
"blocked": st.blocked.contains(&p.id),
+200 -39
View File
@@ -7,6 +7,25 @@
//! `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.
//!
//! **Finality.** The indexer serves the best chain, blocks not yet final
//! included. `squidStatus` exposes `height` and `hash` only (no
//! `finalizedHeight`); `height` is the last final block it processed — relevé
//! on indexer.duniter.org on 2026-10-31: always 3 blocks behind the newest block
//! it serves (`blocks(orderBy: HEIGHT_DESC, first: 1)`), the 2 to 3 blocks of the
//! finality of Duniter v2. Only a comment at or below it settles a ballot: a
//! comment above may still vanish with its block.
//!
//! **What is asked.** Only what may settle the ballot: for a ballot bound to an
//! account, that account's comments; for one bound to none (a Ğecko code), the
//! comments paid to the vote account — a bare copy of a remark costs nothing to
//! send, and must never hide the real one. The engine judges again what comes
//! back (`ld_engine::ballot::judge`): an indexer that ignores the filter changes
//! nothing but the number of pages.
//!
//! **Pages.** In the order of the chain, 50 comments a page, page after page
//! until a comment settles the ballot, a block not yet final is reached, or the
//! last page — at most 20 pages a reading.
use crate::AppState;
use ld_engine::address;
@@ -15,11 +34,19 @@ 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 } } } } }";
/// Validated on the indexer of the Ğ1 chain (indexer.duniter.org), 2026-10-31.
pub const BALLOT_QUERY: &str = "query Ballot($hash: String!, $filter: TxCommentFilter, $after: Cursor) { \
squidStatus { height } txComments(condition: {hash: $hash}, filter: $filter, \
orderBy: [BLOCK_NUMBER_ASC, ID_ASC], first: 50, after: $after) { pageInfo { hasNextPage endCursor } \
nodes { id blockNumber remark author { id identity { index name isMember } } \
transfersByCommentId { nodes { fromId toId amount } } } } }";
/// Pages read at most for one ballot, one reading.
pub const MAX_PAGES: usize = 20;
/// The largest answer read: a page of 50 comments weighs about 30 KB.
const MAX_BODY: usize = 1 << 20;
/// A Ğ1 identity name is 42 characters at most.
const MAX_NAME: usize = 64;
const TIMEOUT: Duration = Duration::from_secs(6);
#[derive(Deserialize)]
@@ -32,7 +59,28 @@ struct Answer {
#[derive(Deserialize)]
#[serde(rename_all = "camelCase")]
struct Data {
tx_comments: Nodes<Node>,
squid_status: Status,
tx_comments: Connection,
}
#[derive(Deserialize)]
struct Status {
height: u32,
}
#[derive(Deserialize)]
#[serde(rename_all = "camelCase")]
struct Connection {
#[serde(default)]
page_info: Option<PageInfo>,
nodes: Vec<Node>,
}
#[derive(Deserialize)]
#[serde(rename_all = "camelCase")]
struct PageInfo {
has_next_page: bool,
end_cursor: Option<String>,
}
#[derive(Deserialize)]
@@ -85,12 +133,23 @@ pub struct Found {
pub name: Option<String>,
}
/// One page of an answer.
#[derive(Debug)]
pub struct Page {
/// The last final block (`squidStatus.height`).
pub finalized: u32,
pub comments: Vec<Found>,
/// The cursor of the next page, if there is one.
pub next: Option<String>,
}
#[derive(Debug)]
pub enum Reading {
/// No indexer answered.
Silent,
/// The comments found (possibly none).
Found(Vec<Found>),
/// What an indexer answered: the comments in final blocks, in the order of the
/// chain (possibly none), and whether others wait in blocks not yet final.
Found { comments: Vec<Found>, unfinal: bool },
}
fn amount(v: &serde_json::Value) -> Option<u64> {
@@ -106,6 +165,9 @@ fn found(node: Node) -> Option<Found> {
if position.block != node.block_number {
return None;
}
if node.author.identity.as_ref().is_some_and(|i| i.name.len() > MAX_NAME) {
return None;
}
let (_, author) = address::decode(&node.author.id)?;
let transfers = node
.transfers_by_comment_id
@@ -133,59 +195,127 @@ fn found(node: Node) -> Option<Found> {
})
}
/// The comments of one answer of the indexer; `None` if it is not one.
pub fn parse(body: &serde_json::Value) -> Option<Vec<Found>> {
/// One page of an answer of the indexer; `None` if it is not one.
pub fn parse(body: &serde_json::Value) -> Option<Page> {
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 data = answer.data?;
let nodes = 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");
let comments: Vec<Found> = nodes.into_iter().filter_map(found).collect();
if comments.len() != count {
tracing::warn!(read = comments.len(), served = count, "indexer: comments that do not read were left aside");
}
Some(out)
let next = data.tx_comments.page_info.filter(|p| p.has_next_page).and_then(|p| p.end_cursor);
Some(Page { finalized: data.squid_status.height, comments, next })
}
/// 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 } });
/// What may settle a ballot, asked of the indexer: the comments of the bound
/// account; unbound, those paid to the vote account — at least the amount.
pub fn filter(account: &str, amount: u64, author: Option<&str>) -> serde_json::Value {
match author {
Some(a) => json!({ "authorId": { "equalTo": a } }),
None => json!({ "transfersByCommentId": { "some": {
"toId": { "equalTo": account },
"amount": { "greaterThanOrEqualTo": amount.to_string() },
} } }),
}
}
/// Reads a body of at most `MAX_BODY` bytes as JSON.
async fn read_json(mut res: reqwest::Response) -> Option<serde_json::Value> {
if res.content_length().is_some_and(|n| n > MAX_BODY as u64) {
return None;
}
let mut buf = Vec::new();
while let Some(chunk) = res.chunk().await.ok()? {
if buf.len() + chunk.len() > MAX_BODY {
return None;
}
buf.extend_from_slice(&chunk);
}
serde_json::from_slice(&buf).ok()
}
async fn page(state: &AppState, url: &str, body: &serde_json::Value) -> Option<Page> {
let res = state.http.post(url).timeout(TIMEOUT).json(body).send().await;
let value = match res {
Ok(r) if r.status().is_success() => read_json(r).await,
Ok(r) => {
tracing::debug!(%url, status = %r.status(), "indexer");
None
}
Err(e) => {
tracing::debug!(%url, error = %e, "indexer");
None
}
};
value.as_ref().and_then(parse)
}
/// The comments in final blocks that carry the remark of hash `remark_hash`
/// (`0x…`) and pass `filter`, page after page until `settled` says one settles
/// the ballot.
pub async fn comments(
state: &AppState,
remark_hash: &str,
filter: &serde_json::Value,
settled: impl Fn(&[Found]) -> bool,
) -> Reading {
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
let mut kept: Vec<Found> = Vec::new();
let mut unfinal = false;
let mut after: Option<String> = None;
let mut finalized: Option<u32> = None;
for n in 0..MAX_PAGES {
let body = json!({ "query": BALLOT_QUERY, "variables": { "hash": remark_hash, "filter": filter, "after": after } });
let Some(p) = page(state, url, &body).await else { break };
// The finality of the first page holds for the reading: later pages may only see more.
let last_final = *finalized.get_or_insert(p.finalized);
answered = true;
for f in p.comments {
if f.comment.position.block > last_final {
unfinal = true;
} else {
kept.push(f);
}
}
Err(e) => {
tracing::debug!(%url, error = %e, "indexer");
None
if unfinal || settled(&kept) {
break;
}
};
match value.as_ref().and_then(parse) {
Some(list) if !list.is_empty() => return Reading::Found(list),
Some(_) => answered = true,
None => {}
match p.next {
Some(cursor) => after = Some(cursor),
None => break,
}
if n + 1 == MAX_PAGES {
tracing::warn!(%url, hash = remark_hash, "indexer: more pages than read; the ballot stays pending");
}
}
if !kept.is_empty() || unfinal {
return Reading::Found { comments: kept, unfinal };
}
}
if answered { Reading::Found(Vec::new()) } else { Reading::Silent }
if answered { Reading::Found { comments: Vec::new(), unfinal: false } } else { Reading::Silent }
}
#[cfg(test)]
mod tests {
use super::*;
/// A real answer of the Ğ1 indexer (2026-10-30), for the remark « Merci ».
/// A real answer of the Ğ1 indexer to `BALLOT_QUERY` (2026-10-31), 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 page = parse(&body).expect("an answer");
assert_eq!(page.finalized, 3_407_492);
assert_eq!(page.next, None, "one page: 26 comments, fewer than 50");
let list = page.comments;
assert_eq!(list.len(), 26, "the old query stopped at 20");
let first = &list[0];
assert_eq!(first.comment.position, ChainPosition { block: 196_944, event: 3 });
assert_eq!(first.remark, "Merci ");
@@ -198,6 +328,8 @@ mod tests {
// An account without identity.
assert_eq!(list[2].comment.identity, None);
assert_eq!(list[2].name, None);
// In the order of the chain.
assert!(list.windows(2).all(|w| w[0].comment.position < w[1].comment.position));
}
#[test]
@@ -205,19 +337,48 @@ mod tests {
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));
// Without the finality, an answer cannot settle anything: no answer.
assert!(parse(&json!({ "data": { "txComments": { "nodes": [] } } })).is_none());
let empty = json!({ "data": { "squidStatus": { "height": 7 }, "txComments": { "nodes": [] } } });
assert_eq!(parse(&empty).map(|p| (p.finalized, p.comments.len(), p.next)), Some((7, 0, None)));
let more = json!({ "data": { "squidStatus": { "height": 7 }, "txComments": {
"pageInfo": { "hasNextPage": true, "endCursor": "abc" }, "nodes": [] } } });
assert_eq!(parse(&more).and_then(|p| p.next).as_deref(), Some("abc"));
}
#[test]
fn a_node_that_does_not_read_is_left_aside() {
let body = json!({ "data": { "txComments": { "nodes": [
let author = "g1NrqKgJW9FoFabaYW82zhS9tDWdQcL1DGpivaJdj7GW9M6iv";
let body = json!({ "data": { "squidStatus": { "height": 100 }, "txComments": { "nodes": [
{ "id": "0000000010-abcde-000002", "blockNumber": 11, "remark": "x",
"author": { "id": "g1NrqKgJW9FoFabaYW82zhS9tDWdQcL1DGpivaJdj7GW9M6iv", "identity": null },
"author": { "id": author, "identity": null },
"transfersByCommentId": { "nodes": [] } },
{ "id": "0000000010-abcde-000002", "blockNumber": 10, "remark": "x",
"author": { "id": "not an address", "identity": null },
"transfersByCommentId": { "nodes": [] } },
{ "id": "0000000010-abcde-000002", "blockNumber": 10, "remark": "x",
"author": { "id": "z".repeat(100_000), "identity": null },
"transfersByCommentId": { "nodes": [] } },
{ "id": "0000000010-abcde-000002", "blockNumber": 10, "remark": "x",
"author": { "id": author, "identity": { "index": 1, "name": "n".repeat(65), "isMember": true } },
"transfersByCommentId": { "nodes": [] } },
] } } });
assert_eq!(parse(&body).map(|l| l.len()), Some(0), "block and id disagree; a bad address");
assert_eq!(
parse(&body).map(|p| p.comments.len()),
Some(0),
"block and id disagree; a bad address; a long one; a name no identity has"
);
}
#[test]
fn what_is_asked_is_what_may_settle() {
let vote = "g1L5EYvvSBaYuQCPW78bHxYcvHfmWAqHYe3Gsrp9SV4kWZaEM";
let alice = "g1Pn1geRZnHcVdWdKkoYwQrAARDTXK1D7H3zuXj7GNayT1thu";
assert_eq!(filter(vote, 10, Some(alice)), json!({ "authorId": { "equalTo": alice } }));
assert_eq!(
filter(vote, 10, None),
json!({ "transfersByCommentId": { "some": {
"toId": { "equalTo": vote }, "amount": { "greaterThanOrEqualTo": "10" } } } })
);
}
}
+2
View File
@@ -121,7 +121,9 @@ pub fn api_router() -> Router<AppState> {
.route("/docs/{id}/proposals", post(proposals::create))
.route("/docs/{id}/votes", post(ballots::vote_many))
.route("/docs/{id}/ballots", post(ballots::create))
.route("/ballots", get(ballots::mine))
.route("/ballots/{id}", get(ballots::status).delete(ballots::drop))
.route("/ballots/{id}/receipt", get(ballots::receipt))
.route("/proposals/{id}", get(proposals::detail).patch(proposals::edit))
.route("/proposals/{id}/submit", post(proposals::submit))
.route("/proposals/{id}/withdraw", post(proposals::withdraw))
+3 -3
View File
@@ -624,7 +624,7 @@ pub async fn detail(
(Some(v), "text") => Some(edit::diff(&v.body, &p.body)),
_ => None,
};
let eval = st.evaluation(unit, &p);
let eval = st.shown_evaluation(unit, &p);
let voted_at = st.voting_level(unit, &p);
let target = p.inertia_target.as_deref().and_then(InertiaLevel::parse);
let tr = docs::translations_for(&state, &[&p]).await?;
@@ -636,7 +636,7 @@ pub async fn detail(
"mine": p.author_id.as_deref() == Some(user.id.as_str()),
"submittedAt": p.submitted_at, "enteredAt": p.entered_at, "confirmedAt": p.confirmed_at,
"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,
"tally": st.shown_tally(&p.id), "evaluation": eval, "votedAtInertia": voted_at,
"blocked": st.blocked.contains(&p.id),
"myVote": my_vote.as_ref().map(|v| &v.choice),
"myVoteStale": my_vote.as_ref().is_some_and(|v| v.revision != p.revision),
@@ -653,7 +653,7 @@ pub async fn detail(
"voteFloor": st.doc.vote_floor,
"participation": st.participation_json(),
// The five levels, with the *for* votes each would ask at this version's participation.
"levels": st.levels_json(st.tally(&p.id).cast()),
"levels": st.levels_json(st.shown_tally(&p.id).cast()),
})))
}
+7 -2
View File
@@ -97,8 +97,13 @@ pub struct JoinBody {
/// `name_key` settles two openings at the same instant.
async fn free_name(state: &AppState, raw: &str) -> AppResult<names::Name> {
let name = names::check(raw).ok_or(AppError::Invalid("name"))?;
// People already there, and those an invitation still waits for.
let mut taken: Vec<String> = sqlx::query_scalar("SELECT display_name FROM identities").fetch_all(&state.db).await?;
// People already there, and those an invitation still waits for. A Ğ1
// identity known only from a vote on the chain (never signed in here) is not
// there: as for any member of the web of trust who never came, its name stays free.
let mut taken: Vec<String> =
sqlx::query_scalar("SELECT display_name FROM identities WHERE NOT (kind = 'g1' AND last_seen_at IS NULL)")
.fetch_all(&state.db)
.await?;
taken.extend(
sqlx::query_scalar::<_, String>(
"SELECT label FROM invitations WHERE used_at IS NULL AND identity_id IS NULL AND expires_at > ?",