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:
+200
-39
@@ -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" } } } })
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user