Files
decision/crates/ld-server/src/indexer.rs
T
YvvandClaude Opus 5.5 3541e1b1a6 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>
2026-10-30 20:52:47 +01:00

224 lines
7.5 KiB
Rust

//! 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");
}
}